Skip to content

Commit

Permalink
Merge pull request #291 from bcgov/feature/restore-version
Browse files Browse the repository at this point in the history
CopyVersion endpoint
  • Loading branch information
jatindersingh93 authored Jan 23, 2025
2 parents e0c56e5 + e69028b commit 261913b
Show file tree
Hide file tree
Showing 7 changed files with 151 additions and 30 deletions.
83 changes: 83 additions & 0 deletions app/src/controllers/object.js
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,89 @@ 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));
let source;
// if COMS versionId parameter is provided, get corresponding Version from S3
if (req.query.versionId) {
const sourceS3VersionId = await getS3VersionId(undefined, addDashesToUuid(req.query.versionId), objId);
source = await storageService.headObject({ filePath: objPath, s3VersionId: sourceS3VersionId, bucketId });
}
// else get most recent Version that is not a delete marker from S3
else {
const vs = await storageService.listObjectVersion({ filePath: objPath, bucketId });
source = vs.Versions
.sort((a, b) => new Date(b.LastModified).getTime() - new Date(a.LastModified).getTime())[0];
}

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: source.VersionId,
bucketId: bucketId
});
const sourceTags = Object.assign({},
...(sourceObject.TagSet?.map(item => ({ [item.Key]: item.Value })) ?? [])
);

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

// update COMS database
await utils.trxWrapper(async (trx) => {
// create or update version (if a non-versioned object)
version = s3Response.VersionId ?
await versionService.copy(
source.VersionId, 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(201).json(version);
} catch (e) {
next(errorToProblem(SERVICE, e));
}
},

/**
* @function listObjectVersion
* List all versions of the object
Expand Down
26 changes: 26 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,32 @@ 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 is copied.
operationId: copyVersion
tags:
- Version
parameters:
- $ref: "#/components/parameters/Path-ObjectId"
- $ref: "#/components/parameters/Query-VersionId"
responses:
"201":
description: Returns details of the new version
content:
application/json:
schema:
$ref: "#/components/schemas/DB-Version"
"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
8 changes: 7 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,12 @@ 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: type.uuidv4
})
},

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 261913b

Please sign in to comment.