Skip to content

Commit 04d96c4

Browse files
authored
feat(storage): add PUT method support for getUrl presigned upload URLs (#14740)
2 parents 0267b5a + a5c45f2 commit 04d96c4

11 files changed

Lines changed: 497 additions & 7 deletions

File tree

.changeset/slow-emus-bow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@aws-amplify/storage': minor
3+
---
4+
5+
feat(storage): add PUT method support for getUrl presigned upload URLs

packages/aws-amplify/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,7 @@
511511
"name": "[Storage] getUrl (S3)",
512512
"path": "./dist/esm/storage/index.mjs",
513513
"import": "{ getUrl }",
514-
"limit": "18.12 kB"
514+
"limit": "18.5 kB"
515515
},
516516
{
517517
"name": "[Storage] list (S3)",

packages/predictions/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
"name": "Predictions",
6767
"path": "./dist/esm/index.mjs",
6868
"import": "{ Predictions }",
69-
"limit": "77 kB"
69+
"limit": "77.5 kB"
7070
}
7171
]
7272
}

packages/storage/__tests__/providers/s3/apis/internal/getUrl.test.ts

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Amplify, StorageAccessLevel } from '@aws-amplify/core';
77
import { getUrl } from '../../../../../src/providers/s3/apis/internal/getUrl';
88
import {
99
getPresignedGetObjectUrl,
10+
getPresignedPutObjectUrl,
1011
headObject,
1112
} from '../../../../../src/providers/s3/utils/client/s3data';
1213
import {
@@ -79,6 +80,7 @@ describe('getUrl test with key', () => {
7980
$metadata: {} as any,
8081
});
8182
jest.mocked(getPresignedGetObjectUrl).mockResolvedValue(mockURL);
83+
jest.mocked(getPresignedPutObjectUrl).mockResolvedValue(mockURL);
8284
});
8385
afterEach(() => {
8486
jest.clearAllMocks();
@@ -225,6 +227,87 @@ describe('getUrl test with key', () => {
225227
);
226228
});
227229
});
230+
231+
describe('method PUT for presigned upload URLs', () => {
232+
it('should generate PUT presigned URL and skip validation', async () => {
233+
await getUrlWrapper({
234+
key: 'key',
235+
options: {
236+
method: 'PUT',
237+
validateObjectExistence: true,
238+
},
239+
});
240+
expect(getPresignedPutObjectUrl).toHaveBeenCalledTimes(1);
241+
expect(headObject).not.toHaveBeenCalled();
242+
await expect(getPresignedPutObjectUrl).toBeLastCalledWithConfigAndInput(
243+
{
244+
credentials,
245+
region,
246+
expiration: expect.any(Number),
247+
},
248+
{
249+
Bucket: bucket,
250+
Key: 'public/key',
251+
},
252+
);
253+
});
254+
255+
it('should include content type and disposition for PUT', async () => {
256+
const contentType = 'image/jpeg';
257+
const contentDisposition = 'attachment; filename="test.jpg"';
258+
const cacheControl = 'max-age=3600';
259+
await getUrlWrapper({
260+
key: 'key',
261+
options: {
262+
method: 'PUT',
263+
contentType,
264+
contentDisposition,
265+
cacheControl,
266+
},
267+
});
268+
expect(getPresignedPutObjectUrl).toHaveBeenCalledTimes(1);
269+
await expect(getPresignedPutObjectUrl).toBeLastCalledWithConfigAndInput(
270+
{
271+
credentials,
272+
region,
273+
expiration: expect.any(Number),
274+
},
275+
{
276+
Bucket: bucket,
277+
Key: 'public/key',
278+
ContentType: contentType,
279+
ContentDisposition: contentDisposition,
280+
CacheControl: cacheControl,
281+
},
282+
);
283+
});
284+
285+
it('should handle object content disposition for PUT', async () => {
286+
await getUrlWrapper({
287+
key: 'key',
288+
options: {
289+
method: 'PUT',
290+
contentDisposition: {
291+
type: 'attachment',
292+
filename: 'test.pdf',
293+
},
294+
},
295+
});
296+
expect(getPresignedPutObjectUrl).toHaveBeenCalledTimes(1);
297+
await expect(getPresignedPutObjectUrl).toBeLastCalledWithConfigAndInput(
298+
{
299+
credentials,
300+
region,
301+
expiration: expect.any(Number),
302+
},
303+
{
304+
Bucket: bucket,
305+
Key: 'public/key',
306+
ContentDisposition: 'attachment; filename="test.pdf"',
307+
},
308+
);
309+
});
310+
});
228311
});
229312
describe('Error cases : With key', () => {
230313
afterAll(() => {
@@ -285,6 +368,7 @@ describe('getUrl test with path', () => {
285368
$metadata: {} as any,
286369
});
287370
jest.mocked(getPresignedGetObjectUrl).mockResolvedValue(mockURL);
371+
jest.mocked(getPresignedPutObjectUrl).mockResolvedValue(mockURL);
288372
});
289373
afterEach(() => {
290374
jest.clearAllMocks();
@@ -418,6 +502,32 @@ describe('getUrl test with path', () => {
418502
);
419503
});
420504
});
505+
506+
describe('method PUT for presigned upload URLs with path', () => {
507+
it('should generate PUT presigned URL with path and skip validation', async () => {
508+
const inputPath = 'uploads/file.jpg';
509+
await getUrlWrapper({
510+
path: inputPath,
511+
options: {
512+
method: 'PUT',
513+
validateObjectExistence: true,
514+
},
515+
});
516+
expect(getPresignedPutObjectUrl).toHaveBeenCalledTimes(1);
517+
expect(headObject).not.toHaveBeenCalled();
518+
await expect(getPresignedPutObjectUrl).toBeLastCalledWithConfigAndInput(
519+
{
520+
credentials,
521+
region,
522+
expiration: expect.any(Number),
523+
},
524+
{
525+
Bucket: bucket,
526+
Key: inputPath,
527+
},
528+
);
529+
});
530+
});
421531
});
422532
describe('Happy cases: With path and Content Disposition, Content Type', () => {
423533
const config = {
@@ -435,6 +545,7 @@ describe('getUrl test with path', () => {
435545
$metadata: {} as any,
436546
});
437547
jest.mocked(getPresignedGetObjectUrl).mockResolvedValue(mockURL);
548+
jest.mocked(getPresignedPutObjectUrl).mockResolvedValue(mockURL);
438549
});
439550
afterEach(() => {
440551
jest.clearAllMocks();
@@ -500,6 +611,7 @@ describe('getUrl test with path', () => {
500611
$metadata: {} as any,
501612
});
502613
jest.mocked(getPresignedGetObjectUrl).mockResolvedValue(mockURL);
614+
jest.mocked(getPresignedPutObjectUrl).mockResolvedValue(mockURL);
503615
});
504616

505617
afterEach(() => {
@@ -660,4 +772,143 @@ describe(`getURL with path and Expected Bucket Owner`, () => {
660772

661773
expect(getPresignedGetObjectUrl).not.toHaveBeenCalled();
662774
});
775+
776+
it('should pass expectedBucketOwner to getPresignedPutObjectUrl for PUT method', async () => {
777+
const path = 'public/expectedbucketowner_test';
778+
779+
await getUrlWrapper({
780+
path,
781+
options: {
782+
method: 'PUT',
783+
expectedBucketOwner: validBucketOwner,
784+
},
785+
});
786+
787+
expect(getPresignedPutObjectUrl).toHaveBeenCalledTimes(1);
788+
await expect(getPresignedPutObjectUrl).toBeLastCalledWithConfigAndInput(
789+
{
790+
credentials,
791+
region,
792+
expiration: expect.any(Number),
793+
},
794+
{
795+
Bucket: bucket,
796+
ExpectedBucketOwner: validBucketOwner,
797+
Key: path,
798+
},
799+
);
800+
});
801+
});
802+
803+
describe('getUrl PUT method with expiresIn and credential expiration', () => {
804+
const getUrlWrapper = (input: GetUrlWithPathInput) => getUrl(Amplify, input);
805+
beforeAll(() => {
806+
mockGetConfig.mockReturnValue({
807+
Storage: {
808+
S3: {
809+
bucket,
810+
region,
811+
buckets: { 'default-bucket': { bucketName: bucket, region } },
812+
},
813+
},
814+
});
815+
});
816+
817+
beforeEach(() => {
818+
jest.mocked(getPresignedPutObjectUrl).mockResolvedValue(mockURL);
819+
});
820+
821+
afterEach(() => {
822+
jest.clearAllMocks();
823+
});
824+
825+
it('should use custom expiresIn for PUT method', async () => {
826+
mockFetchAuthSession.mockResolvedValue({
827+
credentials,
828+
identityId: defaultIdentityId,
829+
});
830+
const path = 'uploads/file.jpg';
831+
832+
await getUrlWrapper({
833+
path,
834+
options: {
835+
method: 'PUT',
836+
expiresIn: 3600,
837+
},
838+
});
839+
840+
expect(getPresignedPutObjectUrl).toHaveBeenCalledTimes(1);
841+
await expect(getPresignedPutObjectUrl).toBeLastCalledWithConfigAndInput(
842+
{
843+
credentials,
844+
region,
845+
expiration: 3600,
846+
},
847+
{
848+
Bucket: bucket,
849+
Key: path,
850+
},
851+
);
852+
});
853+
854+
it('should use credential expiration when it is shorter than expiresIn for PUT method', async () => {
855+
const credentialExpiration = new Date(Date.now() + 600 * 1000);
856+
mockFetchAuthSession.mockResolvedValue({
857+
credentials: { ...credentials, expiration: credentialExpiration },
858+
identityId: defaultIdentityId,
859+
});
860+
const path = 'uploads/file.jpg';
861+
862+
await getUrlWrapper({
863+
path,
864+
options: {
865+
method: 'PUT',
866+
expiresIn: 3600,
867+
},
868+
});
869+
870+
expect(getPresignedPutObjectUrl).toHaveBeenCalledTimes(1);
871+
await expect(getPresignedPutObjectUrl).toBeLastCalledWithConfigAndInput(
872+
{
873+
credentials: { ...credentials, expiration: credentialExpiration },
874+
region,
875+
expiration: expect.any(Number),
876+
},
877+
{
878+
Bucket: bucket,
879+
Key: path,
880+
},
881+
);
882+
const callExpiration = (getPresignedPutObjectUrl as jest.Mock).mock
883+
.calls[0][0].expiration;
884+
expect(callExpiration).toBeLessThanOrEqual(600);
885+
});
886+
887+
it('should use default expiresIn (900s) for PUT method when not specified', async () => {
888+
mockFetchAuthSession.mockResolvedValue({
889+
credentials,
890+
identityId: defaultIdentityId,
891+
});
892+
const path = 'uploads/file.jpg';
893+
894+
await getUrlWrapper({
895+
path,
896+
options: {
897+
method: 'PUT',
898+
},
899+
});
900+
901+
expect(getPresignedPutObjectUrl).toHaveBeenCalledTimes(1);
902+
await expect(getPresignedPutObjectUrl).toBeLastCalledWithConfigAndInput(
903+
{
904+
credentials,
905+
region,
906+
expiration: 900,
907+
},
908+
{
909+
Bucket: bucket,
910+
Key: path,
911+
},
912+
);
913+
});
663914
});

0 commit comments

Comments
 (0)