Skip to content

Commit

Permalink
feat(storage): add path supp to copy API
Browse files Browse the repository at this point in the history
  • Loading branch information
Ashwin Kumar committed Mar 12, 2024
1 parent 32990ee commit 511e83c
Show file tree
Hide file tree
Showing 14 changed files with 338 additions and 67 deletions.
88 changes: 83 additions & 5 deletions packages/storage/__tests__/providers/s3/apis/copy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@

import { AWSCredentials } from '@aws-amplify/core/internals/utils';
import { Amplify } from '@aws-amplify/core';
import { StorageError } from '../../../../src/';
import { StorageValidationErrorCode } from '../../../../src/errors/types/validation';
import { copyObject } from '../../../../src/providers/s3/utils/client';
import { copy } from '../../../../src/providers/s3/apis';
import {
CopySourceOptions,
CopyDestinationOptions,
CopySourceOptionsKey,
CopyDestinationOptionsKey,
} from '../../../../src/providers/s3/types';

jest.mock('../../../../src/providers/s3/utils/client');
Expand Down Expand Up @@ -63,7 +65,7 @@ describe('copy API', () => {
},
});
});
describe('Happy Path Cases:', () => {
describe('Happy Path Cases: with key', () => {
beforeEach(() => {
mockCopyObject.mockImplementation(() => {
return {
Expand Down Expand Up @@ -157,11 +159,11 @@ describe('copy API', () => {
expect(
await copy({
source: {
...(source as CopySourceOptions),
...(source as CopySourceOptionsKey),
key: sourceKey,
},
destination: {
...(destination as CopyDestinationOptions),
...(destination as CopyDestinationOptionsKey),
key: destinationKey,
},
}),
Expand All @@ -177,6 +179,55 @@ describe('copy API', () => {
);
});

describe('Happy Path Cases: with path', () => {
beforeEach(() => {
mockCopyObject.mockImplementation(() => {
return {
Metadata: { key: 'value' },
};
});
});
afterEach(() => {
jest.clearAllMocks();
});

test.each([
{
sourcePath: 'sourcePathAsString',
expectedSourcePath: 'sourcePathAsString',
destinationPath: 'destinationPathAsString',
expectedDestinationPath: 'destinationPathAsString',
},
{
sourcePath: () => 'sourcePathAsFunction',
expectedSourcePath: 'sourcePathAsFunction',
destinationPath: () => 'destinationPathAsFunction',
expectedDestinationPath: 'destinationPathAsFunction',
},
])(
'should copy $sourcePath -> $destinationPath',
async ({
sourcePath,
expectedSourcePath,
destinationPath,
expectedDestinationPath,
}) => {
expect(
await copy({
source: { path: sourcePath },
destination: { path: destinationPath },
}),
).toEqual({ path: expectedDestinationPath });
expect(copyObject).toHaveBeenCalledTimes(1);
expect(copyObject).toHaveBeenCalledWith(copyObjectClientConfig, {
...copyObjectClientBaseParams,
CopySource: `${bucket}/${expectedSourcePath}`,
Key: expectedDestinationPath,
});
},
);
});

describe('Error Path Cases:', () => {
afterEach(() => {
jest.clearAllMocks();
Expand Down Expand Up @@ -206,5 +257,32 @@ describe('copy API', () => {
expect(error.$metadata.httpStatusCode).toBe(404);
}
});

it('should return a path not found error when source uses path and destination uses key', async () => {
try {
// @ts-expect-error
await copy({
source: { path: 'sourcePath' },
destination: { key: 'destinationKey' },
});
} catch (error: any) {
expect(error).toBeInstanceOf(StorageError);
// source uses path so destination expects path as well
expect(error.name).toBe(StorageValidationErrorCode.NoDestinationPath);
}
});

it('should return a key not found error when source uses key and destination uses path', async () => {
try {
// @ts-expect-error
await copy({
source: { key: 'sourcePath' },
destination: { path: 'destinationKey' },
});
} catch (error: any) {
expect(error).toBeInstanceOf(StorageError);
expect(error.name).toBe(StorageValidationErrorCode.NoDestinationKey);
}
});
});
});
8 changes: 8 additions & 0 deletions packages/storage/src/errors/types/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export enum StorageValidationErrorCode {
NoKey = 'NoKey',
NoSourceKey = 'NoSourceKey',
NoDestinationKey = 'NoDestinationKey',
NoSourcePath = 'NoSourcePath',
NoDestinationPath = 'NoDestinationPath',
NoBucket = 'NoBucket',
NoRegion = 'NoRegion',
UrlExpirationMaxLimitExceed = 'UrlExpirationMaxLimitExceed',
Expand All @@ -34,6 +36,12 @@ export const validationErrorMap: AmplifyErrorMap<StorageValidationErrorCode> = {
[StorageValidationErrorCode.NoDestinationKey]: {
message: 'Missing destination key in copy api call.',
},
[StorageValidationErrorCode.NoSourcePath]: {
message: 'Missing source path in copy api call.',
},
[StorageValidationErrorCode.NoDestinationPath]: {
message: 'Missing destination path in copy api call.',
},
[StorageValidationErrorCode.NoBucket]: {
message: 'Missing bucket name while accessing object.',
},
Expand Down
53 changes: 39 additions & 14 deletions packages/storage/src/providers/s3/apis/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,46 @@

import { Amplify } from '@aws-amplify/core';

import { CopyInput, CopyOutput, S3Exception } from '../types';
import {
CopyInput,
CopyInputKey,

Check warning on line 8 in packages/storage/src/providers/s3/apis/copy.ts

View workflow job for this annotation

GitHub Actions / unit-tests / Unit Test - @aws-amplify/storage

Deprecated: Use {@link CopyInputPath} instead
CopyInputPath,
CopyOutput,
CopyOutputKey,

Check warning on line 11 in packages/storage/src/providers/s3/apis/copy.ts

View workflow job for this annotation

GitHub Actions / unit-tests / Unit Test - @aws-amplify/storage

Deprecated: Use {@link CopyOutputPath} instead
CopyOutputPath,
S3Exception,
} from '../types';
import { StorageValidationErrorCode } from '../../../errors/types/validation';

import { copy as copyInternal } from './internal/copy';

/**
* Copy an object from a source object to a new object within the same bucket. Can optionally copy files across
* different level or identityId (if source object's level is 'protected').
*
* @param input - The CopyInput object.
* @returns Output containing the destination key.
* @throws service: {@link S3Exception} - Thrown when checking for existence of the object
* @throws validation: {@link StorageValidationErrorCode } - Thrown when
* source or destination key are not defined.
*/
export const copy = async (input: CopyInput): Promise<CopyOutput> => {
return copyInternal(Amplify, input);
};
interface Copy {
/**
* Copy an object from a source object to a new object within the same bucket.
*
* @param input - The CopyInputPath object.
* @returns Output containing the destination object path.
* @throws service: {@link S3Exception} - Thrown when checking for existence of the object
* @throws validation: {@link StorageValidationErrorCode } - Thrown when
* source or destination path is not defined.
*/
(input: CopyInputPath): Promise<CopyOutputPath>;
/**
* @deprecated The `key` and `accessLevel` parameters are deprecated and will be removed in next major version.
* Please use {@link https://docs.amplify.aws/react/build-a-backend/storage/copy | path} instead.
*
* Copy an object from a source object to a new object within the same bucket. Can optionally copy files across
* different level or identityId (if source object's level is 'protected').
*
* @param input - The CopyInputKey object.
* @returns Output containing the destination object key.
* @throws service: {@link S3Exception} - Thrown when checking for existence of the object
* @throws validation: {@link StorageValidationErrorCode } - Thrown when
* source or destination key is not defined.
*/
(input: CopyInputKey): Promise<CopyOutputKey>;

Check warning on line 43 in packages/storage/src/providers/s3/apis/copy.ts

View workflow job for this annotation

GitHub Actions / unit-tests / Unit Test - @aws-amplify/storage

Deprecated: Use {@link CopyInputPath} instead

Check warning on line 43 in packages/storage/src/providers/s3/apis/copy.ts

View workflow job for this annotation

GitHub Actions / unit-tests / Unit Test - @aws-amplify/storage

Deprecated: Use {@link CopyOutputPath} instead
}

export const copy: Copy = <Output extends CopyOutput>(
input: CopyInput,
): Promise<Output> => copyInternal(Amplify, input) as Promise<Output>;
97 changes: 83 additions & 14 deletions packages/storage/src/providers/s3/apis/internal/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,75 @@
import { AmplifyClassV6 } from '@aws-amplify/core';
import { StorageAction } from '@aws-amplify/core/internals/utils';

import { CopyInput, CopyOutput } from '../../types';
import { resolveS3ConfigAndInput } from '../../utils';
import {
CopyInput,
CopyInputKey,
CopyInputPath,
CopyOutput,
CopyOutputKey,
CopyOutputPath,
} from '../../types';
import {
isInputWithPath,
resolveS3ConfigAndInput,
validateStorageOperationInput,
} from '../../utils';
import { StorageValidationErrorCode } from '../../../../errors/types/validation';
import { assertValidationError } from '../../../../errors/utils/assertValidationError';
import { copyObject } from '../../utils/client';
import { getStorageUserAgentValue } from '../../utils/userAgent';
import { logger } from '../../../../utils';

const isCopyInputWithPath = (input: CopyInput): input is CopyInputPath =>
isInputWithPath(input.source);

export const copy = async (
amplify: AmplifyClassV6,
input: CopyInput,
): Promise<CopyOutput> => {
): Promise<CopyOutput> =>
isCopyInputWithPath(input)
? copyWithPath(amplify, input)
: copyWithKey(amplify, input);

const copyWithPath = async (
amplify: AmplifyClassV6,
input: CopyInputPath,
): Promise<CopyOutputPath> => {
const { source, destination } = input;
const { bucket, identityId } = await resolveS3ConfigAndInput(amplify);

assertValidationError(!!source.path, StorageValidationErrorCode.NoSourcePath);
assertValidationError(
!!destination.path,
StorageValidationErrorCode.NoDestinationPath,
);

const { objectKey: sourceKey } = validateStorageOperationInput(
source,
identityId,
);
const { objectKey: destinationKey } = validateStorageOperationInput(
destination,
identityId,
);

const finalCopySource = `${bucket}/${sourceKey}`;
const finalCopyDestination = destinationKey;
logger.debug(`copying "${finalCopySource}" to "${finalCopyDestination}".`);

await serviceCopy(amplify, {
source: finalCopySource,
destination: finalCopyDestination,
});

return { path: finalCopyDestination };
};

/** @deprecated Use {@link copyWithPath} instead. */
export const copyWithKey = async (
amplify: AmplifyClassV6,
input: CopyInputKey,
): Promise<CopyOutputKey> => {
const {
source: { key: sourceKey },
destination: { key: destinationKey },
Expand All @@ -27,11 +84,10 @@ export const copy = async (
StorageValidationErrorCode.NoDestinationKey,
);

const {
s3Config,
bucket,
keyPrefix: sourceKeyPrefix,
} = await resolveS3ConfigAndInput(amplify, input.source);
const { bucket, keyPrefix: sourceKeyPrefix } = await resolveS3ConfigAndInput(
amplify,
input.source,
);
const { keyPrefix: destinationKeyPrefix } = await resolveS3ConfigAndInput(
amplify,
input.destination,
Expand All @@ -41,20 +97,33 @@ export const copy = async (
const finalCopySource = `${bucket}/${sourceKeyPrefix}${sourceKey}`;
const finalCopyDestination = `${destinationKeyPrefix}${destinationKey}`;
logger.debug(`copying "${finalCopySource}" to "${finalCopyDestination}".`);

await serviceCopy(amplify, {
source: finalCopySource,
destination: finalCopyDestination,
});

return {
key: destinationKey,
};
};

const serviceCopy = async (
amplify: AmplifyClassV6,
input: { source: string; destination: string },
) => {
const { s3Config, bucket } = await resolveS3ConfigAndInput(amplify);

await copyObject(
{
...s3Config,
userAgentValue: getStorageUserAgentValue(StorageAction.Copy),
},
{
Bucket: bucket,
CopySource: finalCopySource,
Key: finalCopyDestination,
CopySource: input.source,
Key: input.destination,
MetadataDirective: 'COPY', // Copies over metadata like contentType as well
},
);

return {
key: destinationKey,
};
};
Loading

0 comments on commit 511e83c

Please sign in to comment.