Skip to content

Commit

Permalink
CopyVersion endpoint
Browse files Browse the repository at this point in the history
Copies a previous version of an object and places on top of the version 'stack'.
If no version is provided to copy, the latest existing version will be copied.
Input validation
Documented in API spec
Fixed a bug with syntax for calling S3 copyObjectCOmmand
  • Loading branch information
TimCsaky committed Dec 14, 2024
1 parent 4b8f276 commit 8ba08ff
Show file tree
Hide file tree
Showing 7 changed files with 142 additions and 30 deletions.
77 changes: 77 additions & 0 deletions app/src/controllers/object.js
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,83 @@ const controller = {
}
},

/**
* @function copyVersion
* Copies a previous version of an object and places on top of the version 'stack'.
* If no version is provided to copy, the latest existing version will be copied.
* @param {object} req Express request object
* @param {object} res Express response object
* @param {function} next The next callback function
* @returns {function} Express middleware function
*/
async copyVersion(req, res, next) {
try {
const bucketId = req.currentObject?.bucketId;
const objId = addDashesToUuid(req.params.objectId);
const objPath = req.currentObject?.path;
const userId = await userService.getCurrentUserId(getCurrentIdentity(req.currentUser, SYSTEM_USER));

// Source S3 Version to copy from
const sourceS3VersionId = await getS3VersionId(
undefined, addDashesToUuid(req.query.versionId), objId
);

const source = await storageService.headObject({ filePath: objPath, s3VersionId: sourceS3VersionId, bucketId });

if (source.ContentLength > MAXCOPYOBJECTLENGTH) {
throw new Error('Cannot copy an object larger than 5GB');
}
// get existing tags on source object, eg: { 'animal': 'bear', colour': 'black' }
const sourceObject = await storageService.getObjectTagging({
filePath: objPath,
s3VersionId: sourceS3VersionId,
bucketId: bucketId
});
const sourceTags = Object.assign({},
...(sourceObject.TagSet?.map(item => ({ [item.Key]: item.Value })) ?? [])
);

// create new version
const data = {
bucketId: bucketId,
copySource: objPath,
filePath: objPath,
metadata: source.Metadata,
tags: sourceTags,
metadataDirective: MetadataDirective.REPLACE,
taggingDirective: TaggingDirective.REPLACE,
s3VersionId: sourceS3VersionId
};
const s3Response = await storageService.copyObject(data);

// update COMS database
await utils.trxWrapper(async (trx) => {
// create or update version (if a non-versioned object)
const version = s3Response.VersionId ?
await versionService.copy(
sourceS3VersionId, s3Response.VersionId, objId, s3Response.CopyObjectResult?.ETag,
s3Response.CopyObjectResult?.LastModified, userId, trx
) :
await versionService.update({
...data,
id: objId,
etag: s3Response.CopyObjectResult?.ETag,
isLatest: true,
lastModifiedDate: s3Response.CopyObjectResult?.LastModified
? new Date(s3Response.CopyObjectResult?.LastModified).toISOString() : undefined
}, userId, trx);
// add metadata to version in DB
await metadataService.associateMetadata(version.id, getKeyValue(data.metadata), userId, trx);
// add tags to new version in DB
await tagService.associateTags(version.id, getKeyValue(data.tags), userId, trx);
});

res.status(204).end();
} catch (e) {
next(errorToProblem(SERVICE, e));
}
},

/**
* @function listObjectVersion
* List all versions of the object
Expand Down
22 changes: 22 additions & 0 deletions app/src/docs/v1.api-spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,28 @@ paths:
default:
$ref: "#/components/responses/Error"
/object/{objectId}/version:
put:
summary: Copy a version to become latest
description: >-
Copies a previous version of an object and places on top of the version 'stack'.
If no version is provided to copy, the latest existing version will be copied.
operationId: copyVersion
tags:
- Version
parameters:
- $ref: "#/components/parameters/Path-ObjectId"
- $ref: "#/components/parameters/Query-VersionId"
responses:
"204":
$ref: "#/components/responses/NoContent"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"422":
$ref: "#/components/responses/UnprocessableEntity"
default:
$ref: "#/components/responses/Error"
get:
summary: List versions for an object
description: >-
Expand Down
9 changes: 8 additions & 1 deletion app/src/routes/v1/object.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ router.head('/:objectId', objectValidator.headObject, currentObject, hasPermissi
router.get('/:objectId', helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } }),
objectValidator.readObject, currentObject, hasPermission(Permissions.READ),
(req, res, next) => {
// TODO: Add validation to reject unexpected query parameters
// TODO: Add validation to reject unexpected query parameters
objectController.readObject(req, res, next);
}
);
Expand All @@ -67,6 +67,13 @@ router.get('/:objectId/version', requireSomeAuth, objectValidator.listObjectVers
}
);

/** creates a new version of an object using either a specified version or latest as the source */
router.put('/:objectId/version', objectValidator.copyVersion,
currentObject, hasPermission(Permissions.UPDATE), (req, res, next) => {
objectController.copyVersion(req, res, next);
});


/** Sets the public flag of an object */
router.patch('/:objectId/public', requireSomeAuth, objectValidator.togglePublic,
currentObject, hasPermission(Permissions.MANAGE), (req, res, next) => {
Expand Down
3 changes: 1 addition & 2 deletions app/src/services/storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,11 @@ const objectStorageService = {
const data = await utils.getBucket(bucketId);
const params = {
Bucket: data.bucket,
CopySource: `${data.bucket}/${copySource}`,
CopySource: `${data.bucket}/${copySource}?versionId=${s3VersionId}`,
Key: filePath,
Metadata: metadata,
MetadataDirective: metadataDirective,
TaggingDirective: taggingDirective,
VersionId: s3VersionId
};

if (tags) {
Expand Down
33 changes: 18 additions & 15 deletions app/src/services/version.js
Original file line number Diff line number Diff line change
Expand Up @@ -301,23 +301,26 @@ const service = {
filePath: object.path,
bucketId: object.bucketId
});
const latestS3VersionId = s3Versions.DeleteMarkers
.concat(s3Versions.Versions)
.filter((v) => v.IsLatest)[0].VersionId;

// get same version from COMS db
const current = await Version.query(trx)
.first()
.where({ objectId: objectId, s3VersionId: latestS3VersionId })
.throwIfNotFound();
let updated;
// update as latest if not already and fetch
if (!current.isLatest) {
updated = await Version.query(trx)
.updateAndFetchById(current.id, { isLatest: true });
let updated, current;
if(s3Versions.DeleteMarkers.concat(s3Versions.Versions).length > 0){
const latestS3VersionId = s3Versions.DeleteMarkers
.concat(s3Versions.Versions)
.filter((v) => v.IsLatest)[0].VersionId;
// get same version from COMS db
current = await Version.query(trx)
.first()
.where({ objectId: objectId, s3VersionId: latestS3VersionId })
.throwIfNotFound();

// update as latest if not already and fetch
if (!current.isLatest) {
updated = await Version.query(trx)
.updateAndFetchById(current.id, { isLatest: true });
}
// set other versions in COMS db to isLatest=false
await service.removeDuplicateLatest(current.id, current.objectId, trx);
}
// set other versions in COMS db to isLatest=false
await service.removeDuplicateLatest(current.id, current.objectId, trx);

if (!etrx) await trx.commit();
return Promise.resolve(updated ?? current);
Expand Down
10 changes: 10 additions & 0 deletions app/src/validators/object.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ const schema = {
})
},

copyVersion: {
params: Joi.object({
objectId: type.uuidv4
}),
query: Joi.object({
versionId: scheme.guid
})
},

readObject: {
params: Joi.object({
objectId: type.uuidv4
Expand Down Expand Up @@ -190,6 +199,7 @@ const validator = {
fetchTags: validate(schema.fetchTags),
headObject: validate(schema.headObject),
listObjectVersion: validate(schema.listObjectVersion),
copyVersion: validate(schema.copyVersion),
readObject: validate(schema.readObject),
replaceMetadata: validate(schema.replaceMetadata),
replaceTags: validate(schema.replaceTags),
Expand Down
18 changes: 6 additions & 12 deletions app/tests/unit/services/storage.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,11 @@ describe('copyObject', () => {
expect(s3ClientMock).toHaveReceivedCommandTimes(CopyObjectCommand, 1);
expect(s3ClientMock).toHaveReceivedCommandWith(CopyObjectCommand, {
Bucket: bucket,
CopySource: `${bucket}/${copySource}`,
CopySource: `${bucket}/${copySource}?versionId=${undefined}`,
Key: filePath,
Metadata: undefined,
MetadataDirective: MetadataDirective.COPY,
TaggingDirective: TaggingDirective.COPY,
VersionId: undefined
});
});

Expand All @@ -103,12 +102,11 @@ describe('copyObject', () => {
expect(s3ClientMock).toHaveReceivedCommandTimes(CopyObjectCommand, 1);
expect(s3ClientMock).toHaveReceivedCommandWith(CopyObjectCommand, {
Bucket: bucket,
CopySource: `${bucket}/${copySource}`,
CopySource: `${bucket}/${copySource}?versionId=1234`,
Key: filePath,
Metadata: undefined,
MetadataDirective: MetadataDirective.COPY,
TaggingDirective: TaggingDirective.COPY,
VersionId: s3VersionId
});
});

Expand All @@ -124,12 +122,11 @@ describe('copyObject', () => {
expect(s3ClientMock).toHaveReceivedCommandTimes(CopyObjectCommand, 1);
expect(s3ClientMock).toHaveReceivedCommandWith(CopyObjectCommand, {
Bucket: bucket,
CopySource: `${bucket}/${copySource}`,
CopySource: `${bucket}/${copySource}?versionId=${undefined}`,
Key: filePath,
Metadata: metadata,
MetadataDirective: metadataDirective,
TaggingDirective: TaggingDirective.COPY,
VersionId: undefined
});
});

Expand All @@ -146,12 +143,11 @@ describe('copyObject', () => {
expect(s3ClientMock).toHaveReceivedCommandTimes(CopyObjectCommand, 1);
expect(s3ClientMock).toHaveReceivedCommandWith(CopyObjectCommand, {
Bucket: bucket,
CopySource: `${bucket}/${copySource}`,
CopySource: `${bucket}/${copySource}?versionId=1234`,
Key: filePath,
Metadata: metadata,
MetadataDirective: metadataDirective,
TaggingDirective: TaggingDirective.COPY,
VersionId: s3VersionId
});
});

Expand All @@ -167,13 +163,12 @@ describe('copyObject', () => {
expect(s3ClientMock).toHaveReceivedCommandTimes(CopyObjectCommand, 1);
expect(s3ClientMock).toHaveReceivedCommandWith(CopyObjectCommand, {
Bucket: bucket,
CopySource: `${bucket}/${copySource}`,
CopySource: `${bucket}/${copySource}?versionId=${undefined}`,
Key: filePath,
Metadata: undefined,
MetadataDirective: MetadataDirective.COPY,
Tagging: 'test=123',
TaggingDirective: taggingDirective,
VersionId: undefined
});
});

Expand All @@ -190,13 +185,12 @@ describe('copyObject', () => {
expect(s3ClientMock).toHaveReceivedCommandTimes(CopyObjectCommand, 1);
expect(s3ClientMock).toHaveReceivedCommandWith(CopyObjectCommand, {
Bucket: bucket,
CopySource: `${bucket}/${copySource}`,
CopySource: `${bucket}/${copySource}?versionId=1234`,
Key: filePath,
Metadata: undefined,
MetadataDirective: MetadataDirective.COPY,
Tagging: 'test=123',
TaggingDirective: taggingDirective,
VersionId: s3VersionId
});
});
});
Expand Down

0 comments on commit 8ba08ff

Please sign in to comment.