diff --git a/packages/storage/__tests__/providers/s3/apis/internal/downloadData.test.ts b/packages/storage/__tests__/providers/s3/apis/internal/downloadData.test.ts index cb0dafdaf27..7cc077c1aaa 100644 --- a/packages/storage/__tests__/providers/s3/apis/internal/downloadData.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/internal/downloadData.test.ts @@ -310,6 +310,48 @@ describe('downloadData with key', () => { ); }); }); + + describe('ResponseCacheControl passed in options', () => { + it('should include cacheControl in headers when provided', async () => { + (getObject as jest.Mock).mockResolvedValueOnce({ Body: 'body' }); + downloadData({ + path: inputKey, + options: { + cacheControl: 'no-store', + }, + }); + + const { job } = mockCreateDownloadTask.mock.calls[0][0]; + await job(); + + expect(getObject).toHaveBeenCalledTimes(1); + await expect(getObject).toBeLastCalledWithConfigAndInput( + expect.any(Object), + expect.objectContaining({ + ResponseCacheControl: 'no-store', + }), + ); + }); + + it('should NOT include cacheControl in headers when not provided', async () => { + (getObject as jest.Mock).mockResolvedValueOnce({ Body: 'body' }); + downloadData({ + path: inputKey, + }); + + const { job } = mockCreateDownloadTask.mock.calls[0][0]; + await job(); + + expect(getObject).toHaveBeenCalledTimes(1); + await expect(getObject).toBeLastCalledWithConfigAndInput( + expect.any(Object), + { + Bucket: bucket, + Key: 'public/key', + }, + ); + }); + }); }); describe('downloadData with path', () => { @@ -544,4 +586,46 @@ describe('downloadData with path', () => { ); }); }); + + describe('ResponseCacheControl passed in options', () => { + it('should include cacheControl in headers when provided', async () => { + (getObject as jest.Mock).mockResolvedValueOnce({ Body: 'body' }); + downloadData({ + path: inputKey, + options: { + cacheControl: 'no-store', + }, + }); + + const { job } = mockCreateDownloadTask.mock.calls[0][0]; + await job(); + + expect(getObject).toHaveBeenCalledTimes(1); + await expect(getObject).toBeLastCalledWithConfigAndInput( + expect.any(Object), + expect.objectContaining({ + ResponseCacheControl: 'no-store', + }), + ); + }); + + it('should NOT include cacheControl in headers when not provided', async () => { + (getObject as jest.Mock).mockResolvedValueOnce({ Body: 'body' }); + downloadData({ + path: inputKey, + }); + + const { job } = mockCreateDownloadTask.mock.calls[0][0]; + await job(); + + expect(getObject).toHaveBeenCalledTimes(1); + await expect(getObject).toBeLastCalledWithConfigAndInput( + expect.any(Object), + { + Bucket: bucket, + Key: inputPath, + }, + ); + }); + }); }); diff --git a/packages/storage/__tests__/providers/s3/apis/internal/getUrl.test.ts b/packages/storage/__tests__/providers/s3/apis/internal/getUrl.test.ts index 03ade454d44..c9b18d8beac 100644 --- a/packages/storage/__tests__/providers/s3/apis/internal/getUrl.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/internal/getUrl.test.ts @@ -182,6 +182,49 @@ describe('getUrl test with key', () => { ); }); }); + + describe('cacheControl passed in options', () => { + it('should include ResponseCacheControl header', async () => { + const cacheControl = 'no-store'; + await getUrlWrapper({ + key: 'key', + options: { + cacheControl, + }, + }); + expect(getPresignedGetObjectUrl).toHaveBeenCalledTimes(1); + await expect(getPresignedGetObjectUrl).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + expiration: expect.any(Number), + }, + { + Bucket: bucket, + Key: 'public/key', + ResponseCacheControl: cacheControl, + }, + ); + }); + + it('should NOT include ResponseCacheControl header', async () => { + await getUrlWrapper({ + key: 'key', + }); + expect(getPresignedGetObjectUrl).toHaveBeenCalledTimes(1); + await expect(getPresignedGetObjectUrl).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + expiration: expect.any(Number), + }, + { + Bucket: bucket, + Key: 'public/key', + }, + ); + }); + }); }); describe('Error cases : With key', () => { afterAll(() => { @@ -330,6 +373,51 @@ describe('getUrl test with path', () => { ); }); }); + + describe('cacheControl passed in options', () => { + it('should include ResponseCacheControl header', async () => { + const inputPath = 'path/'; + const cacheControl = 'no-store'; + await getUrlWrapper({ + path: inputPath, + options: { + cacheControl, + }, + }); + expect(getPresignedGetObjectUrl).toHaveBeenCalledTimes(1); + await expect(getPresignedGetObjectUrl).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + expiration: expect.any(Number), + }, + { + Bucket: bucket, + Key: inputPath, + ResponseCacheControl: cacheControl, + }, + ); + }); + + it('should not include ResponseCacheControl header', async () => { + const inputPath = 'path/'; + await getUrlWrapper({ + path: inputPath, + }); + expect(getPresignedGetObjectUrl).toHaveBeenCalledTimes(1); + await expect(getPresignedGetObjectUrl).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + expiration: expect.any(Number), + }, + { + Bucket: bucket, + Key: inputPath, + }, + ); + }); + }); }); describe('Happy cases: With path and Content Disposition, Content Type', () => { const config = { diff --git a/packages/storage/__tests__/providers/s3/apis/internal/uploadData/putObjectJob.test.ts b/packages/storage/__tests__/providers/s3/apis/internal/uploadData/putObjectJob.test.ts index 2665fdef227..cfc617a1051 100644 --- a/packages/storage/__tests__/providers/s3/apis/internal/uploadData/putObjectJob.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/internal/uploadData/putObjectJob.test.ts @@ -235,6 +235,30 @@ describe('putObjectJob with key', () => { ); }); }); + + describe('cacheControl passed in option', () => { + it('should include CacheControl header', async () => { + const job = putObjectJob( + { + path: testPath, + data, + options: { + cacheControl: 'no-store', + }, + }, + new AbortController().signal, + dataLength, + ); + await job(); + + await expect(mockPutObject).toBeLastCalledWithConfigAndInput( + expect.objectContaining({ credentials, region }), + expect.objectContaining({ + CacheControl: 'no-store', + }), + ); + }); + }); }); describe('putObjectJob with path', () => { @@ -454,4 +478,50 @@ describe('putObjectJob with path', () => { ); }); }); + + describe('cacheControl passed in option', () => { + it('should include CacheControl header', async () => { + const job = putObjectJob( + { + path: testPath, + data, + options: { + cacheControl: 'no-store', + }, + }, + new AbortController().signal, + dataLength, + ); + await job(); + + await expect(mockPutObject).toBeLastCalledWithConfigAndInput( + expect.objectContaining({ credentials, region }), + expect.objectContaining({ + CacheControl: 'no-store', + }), + ); + }); + + it('should NOT include CacheControl header', async () => { + const job = putObjectJob( + { + path: testPath, + data, + }, + new AbortController().signal, + dataLength, + ); + await job(); + + await expect(mockPutObject).toBeLastCalledWithConfigAndInput( + expect.objectContaining({ credentials, region }), + { + Bucket: bucket, + Key: testPath, + Body: data, + ContentType: 'application/octet-stream', + }, + ); + }); + }); }); diff --git a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/headObject.ts b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/headObject.ts index a392e121c8c..983f4f113b9 100644 --- a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/headObject.ts +++ b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/headObject.ts @@ -22,7 +22,7 @@ const headObjectHappyCase: ApiFunctionalTestCase = [ }, expect.objectContaining({ url: expect.objectContaining({ - href: 'https://bucket.s3.us-east-1.amazonaws.com/key', + href: 'https://bucket.s3.us-east-1.amazonaws.com/key?response-cache-control=no-store', }), method: 'HEAD', }), @@ -65,7 +65,7 @@ const headObjectHappyCaseCustomEndpoint: ApiFunctionalTestCase< }, expect.objectContaining({ url: expect.objectContaining({ - href: 'https://custom.endpoint.com/bucket/key', + href: 'https://custom.endpoint.com/bucket/key?response-cache-control=no-store', }), }), { diff --git a/packages/storage/src/providers/s3/apis/internal/downloadData.ts b/packages/storage/src/providers/s3/apis/internal/downloadData.ts index f1d804b323d..80283acfcb1 100644 --- a/packages/storage/src/providers/s3/apis/internal/downloadData.ts +++ b/packages/storage/src/providers/s3/apis/internal/downloadData.ts @@ -77,6 +77,7 @@ const downloadDataJob = ...(downloadDataOptions?.bytesRange && { Range: `bytes=${downloadDataOptions.bytesRange.start}-${downloadDataOptions.bytesRange.end}`, }), + ResponseCacheControl: downloadDataOptions?.cacheControl, ExpectedBucketOwner: downloadDataOptions?.expectedBucketOwner, }, ); diff --git a/packages/storage/src/providers/s3/apis/internal/getUrl.ts b/packages/storage/src/providers/s3/apis/internal/getUrl.ts index db2315ddb78..6334a9717cd 100644 --- a/packages/storage/src/providers/s3/apis/internal/getUrl.ts +++ b/packages/storage/src/providers/s3/apis/internal/getUrl.ts @@ -82,6 +82,7 @@ export const getUrl = async ( ...(getUrlOptions?.contentType && { ResponseContentType: getUrlOptions.contentType, }), + ResponseCacheControl: getUrlOptions?.cacheControl, ExpectedBucketOwner: getUrlOptions?.expectedBucketOwner, }, ), diff --git a/packages/storage/src/providers/s3/apis/internal/uploadData/putObjectJob.ts b/packages/storage/src/providers/s3/apis/internal/uploadData/putObjectJob.ts index 13443b9e9ae..719a76ecb1e 100644 --- a/packages/storage/src/providers/s3/apis/internal/uploadData/putObjectJob.ts +++ b/packages/storage/src/providers/s3/apis/internal/uploadData/putObjectJob.ts @@ -66,6 +66,7 @@ export const putObjectJob = checksumAlgorithm, onProgress, expectedBucketOwner, + cacheControl, } = uploadDataOptions ?? {}; const checksumCRC32 = @@ -93,6 +94,7 @@ export const putObjectJob = ContentType: contentType, ContentDisposition: constructContentDisposition(contentDisposition), ContentEncoding: contentEncoding, + CacheControl: cacheControl, Metadata: metadata, ContentMD5: contentMD5, ChecksumCRC32: checksumCRC32, diff --git a/packages/storage/src/providers/s3/types/options.ts b/packages/storage/src/providers/s3/types/options.ts index 39891185185..cfaba32565d 100644 --- a/packages/storage/src/providers/s3/types/options.ts +++ b/packages/storage/src/providers/s3/types/options.ts @@ -181,6 +181,11 @@ export type GetUrlOptions = CommonOptions & { * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type */ contentType?: string; + /** + * The cache-control header value of the file when downloading it. + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control + */ + cacheControl?: string; }; /** @deprecated Use {@link GetUrlWithPathOptions} instead. */ @@ -192,7 +197,13 @@ export type GetUrlWithPathOptions = GetUrlOptions; */ export type DownloadDataOptions = CommonOptions & TransferOptions & - BytesRangeOptions; + BytesRangeOptions & { + /** + * The cache-control header value of the file when downloading it. + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control + */ + cacheControl?: string; + }; /** @deprecated Use {@link DownloadDataWithPathOptions} instead. */ export type DownloadDataWithKeyOptions = ReadOptions & DownloadDataOptions; @@ -236,6 +247,11 @@ export type UploadDataOptions = CommonOptions & * @default undefined */ checksumAlgorithm?: UploadDataChecksumAlgorithm; + /** + * The cache-control header value of the file when downloading it. + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control + */ + cacheControl?: string; }; /** @deprecated Use {@link UploadDataWithPathOptions} instead. */ diff --git a/packages/storage/src/providers/s3/utils/client/s3data/getObject.ts b/packages/storage/src/providers/s3/utils/client/s3data/getObject.ts index 40d8e067892..2726329eed7 100644 --- a/packages/storage/src/providers/s3/utils/client/s3data/getObject.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/getObject.ts @@ -53,6 +53,7 @@ export type GetObjectInput = Pick< | 'ResponseContentDisposition' | 'ResponseContentType' | 'ExpectedBucketOwner' + | 'ResponseCacheControl' >; export type GetObjectOutput = GetObjectCommandOutput; @@ -66,6 +67,9 @@ const getObjectSerializer = async ( url.pathname = serializePathnameObjectKey(url, input.Key); url.search = new AmplifyUrlSearchParams({ 'x-id': 'GetObject', + ...(input.ResponseCacheControl && { + 'response-cache-control': input.ResponseCacheControl, + }), }).toString(); validateObjectUrl({ bucketName: input.Bucket, diff --git a/packages/storage/src/providers/s3/utils/client/s3data/headObject.ts b/packages/storage/src/providers/s3/utils/client/s3data/headObject.ts index 0825cbc66a9..b94fdf50c40 100644 --- a/packages/storage/src/providers/s3/utils/client/s3data/headObject.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/headObject.ts @@ -7,7 +7,10 @@ import { HttpResponse, parseMetadata, } from '@aws-amplify/core/internals/aws-client-utils'; -import { AmplifyUrl } from '@aws-amplify/core/internals/utils'; +import { + AmplifyUrl, + AmplifyUrlSearchParams, +} from '@aws-amplify/core/internals/utils'; import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; import { @@ -49,6 +52,9 @@ const headObjectSerializer = async ( const url = new AmplifyUrl(endpoint.url.toString()); validateS3RequiredParameter(!!input.Key, 'Key'); url.pathname = serializePathnameObjectKey(url, input.Key); + url.search = new AmplifyUrlSearchParams({ + 'response-cache-control': 'no-store', + }).toString(); validateObjectUrl({ bucketName: input.Bucket, key: input.Key,