Skip to content

Commit ec2212d

Browse files
jatindersingh93TimCsaky
authored andcommitted
Merge pull request #291 from bcgov/feature/restore-version
CopyVersion endpoint
2 parents e0c56e5 + e69028b commit ec2212d

File tree

7 files changed

+152
-30
lines changed

7 files changed

+152
-30
lines changed

app/src/controllers/object.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,90 @@ const controller = {
716716
}
717717
},
718718

719+
/**
720+
* @function copyVersion
721+
* Copies a previous version of an object and places on top of the version 'stack'.
722+
* If no version is provided to copy, the latest existing version will be copied.
723+
* @param {object} req Express request object
724+
* @param {object} res Express response object
725+
* @param {function} next The next callback function
726+
* @returns {function} Express middleware function
727+
*/
728+
async copyVersion(req, res, next) {
729+
try {
730+
const bucketId = req.currentObject?.bucketId;
731+
const objId = addDashesToUuid(req.params.objectId);
732+
const objPath = req.currentObject?.path;
733+
const userId = await userService.getCurrentUserId(getCurrentIdentity(req.currentUser, SYSTEM_USER));
734+
let source;
735+
// if COMS versionId parameter is provided, get corresponding Version from S3
736+
if (req.query.versionId) {
737+
const sourceS3VersionId = await getS3VersionId(undefined, addDashesToUuid(req.query.versionId), objId);
738+
source = await storageService.headObject({ filePath: objPath, s3VersionId: sourceS3VersionId, bucketId });
739+
}
740+
// else get most recent Version that is not a delete marker from S3
741+
else {
742+
const vs = await storageService.listObjectVersion({ filePath: objPath, bucketId });
743+
const sourceS3VersionId = vs.Versions
744+
.sort((a, b) => new Date(b.LastModified).getTime() - new Date(a.LastModified).getTime())[0].VersionId;
745+
source = await storageService.headObject({ filePath: objPath, s3VersionId: sourceS3VersionId, bucketId });
746+
}
747+
748+
if (source.ContentLength > MAXCOPYOBJECTLENGTH) {
749+
throw new Error('Cannot copy an object larger than 5GB');
750+
}
751+
// get existing tags on source object, eg: { 'animal': 'bear', colour': 'black' }
752+
const sourceObject = await storageService.getObjectTagging({
753+
filePath: objPath,
754+
s3VersionId: source.VersionId,
755+
bucketId: bucketId
756+
});
757+
const sourceTags = Object.assign({},
758+
...(sourceObject.TagSet?.map(item => ({ [item.Key]: item.Value })) ?? [])
759+
);
760+
761+
// create new version in S3
762+
const data = {
763+
bucketId: bucketId,
764+
copySource: objPath,
765+
filePath: objPath,
766+
metadata: source.Metadata,
767+
tags: sourceTags,
768+
metadataDirective: MetadataDirective.REPLACE,
769+
taggingDirective: TaggingDirective.REPLACE,
770+
s3VersionId: source.VersionId
771+
};
772+
const s3Response = await storageService.copyObject(data);
773+
let version;
774+
775+
// update COMS database
776+
await utils.trxWrapper(async (trx) => {
777+
// create or update version (if a non-versioned object)
778+
version = s3Response.VersionId ?
779+
await versionService.copy(
780+
source.VersionId, s3Response.VersionId, objId, s3Response.CopyObjectResult?.ETag,
781+
s3Response.CopyObjectResult?.LastModified, userId, trx
782+
) :
783+
await versionService.update({
784+
...data,
785+
id: objId,
786+
etag: s3Response.CopyObjectResult?.ETag,
787+
isLatest: true,
788+
lastModifiedDate: s3Response.CopyObjectResult?.LastModified
789+
? new Date(s3Response.CopyObjectResult?.LastModified).toISOString() : undefined
790+
}, userId, trx);
791+
// add metadata to version in DB
792+
await metadataService.associateMetadata(version.id, getKeyValue(data.metadata), userId, trx);
793+
// add tags to new version in DB
794+
await tagService.associateTags(version.id, getKeyValue(data.tags), userId, trx);
795+
});
796+
797+
res.status(201).json(version);
798+
} catch (e) {
799+
next(errorToProblem(SERVICE, e));
800+
}
801+
},
802+
719803
/**
720804
* @function listObjectVersion
721805
* List all versions of the object

app/src/docs/v1.api-spec.yaml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,32 @@ paths:
652652
default:
653653
$ref: "#/components/responses/Error"
654654
/object/{objectId}/version:
655+
put:
656+
summary: Copy a version to become latest
657+
description: >-
658+
Copies a previous version of an object and places on top of the version 'stack'.
659+
If no version is provided to copy, the latest existing version is copied.
660+
operationId: copyVersion
661+
tags:
662+
- Version
663+
parameters:
664+
- $ref: "#/components/parameters/Path-ObjectId"
665+
- $ref: "#/components/parameters/Query-VersionId"
666+
responses:
667+
"201":
668+
description: Returns details of the new version
669+
content:
670+
application/json:
671+
schema:
672+
$ref: "#/components/schemas/DB-Version"
673+
"401":
674+
$ref: "#/components/responses/Unauthorized"
675+
"403":
676+
$ref: "#/components/responses/Forbidden"
677+
"422":
678+
$ref: "#/components/responses/UnprocessableEntity"
679+
default:
680+
$ref: "#/components/responses/Error"
655681
get:
656682
summary: List versions for an object
657683
description: >-

app/src/routes/v1/object.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ router.head('/:objectId', objectValidator.headObject, currentObject, hasPermissi
4141
router.get('/:objectId', helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } }),
4242
objectValidator.readObject, currentObject, hasPermission(Permissions.READ),
4343
(req, res, next) => {
44-
// TODO: Add validation to reject unexpected query parameters
44+
// TODO: Add validation to reject unexpected query parameters
4545
objectController.readObject(req, res, next);
4646
}
4747
);
@@ -67,6 +67,12 @@ router.get('/:objectId/version', requireSomeAuth, objectValidator.listObjectVers
6767
}
6868
);
6969

70+
/** creates a new version of an object using either a specified version or latest as the source */
71+
router.put('/:objectId/version', objectValidator.copyVersion,
72+
currentObject, hasPermission(Permissions.UPDATE), (req, res, next) => {
73+
objectController.copyVersion(req, res, next);
74+
});
75+
7076
/** Sets the public flag of an object */
7177
router.patch('/:objectId/public', requireSomeAuth, objectValidator.togglePublic,
7278
currentObject, hasPermission(Permissions.MANAGE), (req, res, next) => {

app/src/services/storage.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,11 @@ const objectStorageService = {
8989
const data = await utils.getBucket(bucketId);
9090
const params = {
9191
Bucket: data.bucket,
92-
CopySource: `${data.bucket}/${copySource}`,
92+
CopySource: `${data.bucket}/${copySource}?versionId=${s3VersionId}`,
9393
Key: filePath,
9494
Metadata: metadata,
9595
MetadataDirective: metadataDirective,
9696
TaggingDirective: taggingDirective,
97-
VersionId: s3VersionId
9897
};
9998

10099
if (tags) {

app/src/services/version.js

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -301,23 +301,26 @@ const service = {
301301
filePath: object.path,
302302
bucketId: object.bucketId
303303
});
304-
const latestS3VersionId = s3Versions.DeleteMarkers
305-
.concat(s3Versions.Versions)
306-
.filter((v) => v.IsLatest)[0].VersionId;
307304

308-
// get same version from COMS db
309-
const current = await Version.query(trx)
310-
.first()
311-
.where({ objectId: objectId, s3VersionId: latestS3VersionId })
312-
.throwIfNotFound();
313-
let updated;
314-
// update as latest if not already and fetch
315-
if (!current.isLatest) {
316-
updated = await Version.query(trx)
317-
.updateAndFetchById(current.id, { isLatest: true });
305+
let updated, current;
306+
if(s3Versions.DeleteMarkers.concat(s3Versions.Versions).length > 0){
307+
const latestS3VersionId = s3Versions.DeleteMarkers
308+
.concat(s3Versions.Versions)
309+
.filter((v) => v.IsLatest)[0].VersionId;
310+
// get same version from COMS db
311+
current = await Version.query(trx)
312+
.first()
313+
.where({ objectId: objectId, s3VersionId: latestS3VersionId })
314+
.throwIfNotFound();
315+
316+
// update as latest if not already and fetch
317+
if (!current.isLatest) {
318+
updated = await Version.query(trx)
319+
.updateAndFetchById(current.id, { isLatest: true });
320+
}
321+
// set other versions in COMS db to isLatest=false
322+
await service.removeDuplicateLatest(current.id, current.objectId, trx);
318323
}
319-
// set other versions in COMS db to isLatest=false
320-
await service.removeDuplicateLatest(current.id, current.objectId, trx);
321324

322325
if (!etrx) await trx.commit();
323326
return Promise.resolve(updated ?? current);

app/src/validators/object.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,15 @@ const schema = {
9191
})
9292
},
9393

94+
copyVersion: {
95+
params: Joi.object({
96+
objectId: type.uuidv4
97+
}),
98+
query: Joi.object({
99+
versionId: type.uuidv4
100+
})
101+
},
102+
94103
readObject: {
95104
params: Joi.object({
96105
objectId: type.uuidv4
@@ -190,6 +199,7 @@ const validator = {
190199
fetchTags: validate(schema.fetchTags),
191200
headObject: validate(schema.headObject),
192201
listObjectVersion: validate(schema.listObjectVersion),
202+
copyVersion: validate(schema.copyVersion),
193203
readObject: validate(schema.readObject),
194204
replaceMetadata: validate(schema.replaceMetadata),
195205
replaceTags: validate(schema.replaceTags),

app/tests/unit/services/storage.spec.js

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,11 @@ describe('copyObject', () => {
8383
expect(s3ClientMock).toHaveReceivedCommandTimes(CopyObjectCommand, 1);
8484
expect(s3ClientMock).toHaveReceivedCommandWith(CopyObjectCommand, {
8585
Bucket: bucket,
86-
CopySource: `${bucket}/${copySource}`,
86+
CopySource: `${bucket}/${copySource}?versionId=${undefined}`,
8787
Key: filePath,
8888
Metadata: undefined,
8989
MetadataDirective: MetadataDirective.COPY,
9090
TaggingDirective: TaggingDirective.COPY,
91-
VersionId: undefined
9291
});
9392
});
9493

@@ -103,12 +102,11 @@ describe('copyObject', () => {
103102
expect(s3ClientMock).toHaveReceivedCommandTimes(CopyObjectCommand, 1);
104103
expect(s3ClientMock).toHaveReceivedCommandWith(CopyObjectCommand, {
105104
Bucket: bucket,
106-
CopySource: `${bucket}/${copySource}`,
105+
CopySource: `${bucket}/${copySource}?versionId=1234`,
107106
Key: filePath,
108107
Metadata: undefined,
109108
MetadataDirective: MetadataDirective.COPY,
110109
TaggingDirective: TaggingDirective.COPY,
111-
VersionId: s3VersionId
112110
});
113111
});
114112

@@ -124,12 +122,11 @@ describe('copyObject', () => {
124122
expect(s3ClientMock).toHaveReceivedCommandTimes(CopyObjectCommand, 1);
125123
expect(s3ClientMock).toHaveReceivedCommandWith(CopyObjectCommand, {
126124
Bucket: bucket,
127-
CopySource: `${bucket}/${copySource}`,
125+
CopySource: `${bucket}/${copySource}?versionId=${undefined}`,
128126
Key: filePath,
129127
Metadata: metadata,
130128
MetadataDirective: metadataDirective,
131129
TaggingDirective: TaggingDirective.COPY,
132-
VersionId: undefined
133130
});
134131
});
135132

@@ -146,12 +143,11 @@ describe('copyObject', () => {
146143
expect(s3ClientMock).toHaveReceivedCommandTimes(CopyObjectCommand, 1);
147144
expect(s3ClientMock).toHaveReceivedCommandWith(CopyObjectCommand, {
148145
Bucket: bucket,
149-
CopySource: `${bucket}/${copySource}`,
146+
CopySource: `${bucket}/${copySource}?versionId=1234`,
150147
Key: filePath,
151148
Metadata: metadata,
152149
MetadataDirective: metadataDirective,
153150
TaggingDirective: TaggingDirective.COPY,
154-
VersionId: s3VersionId
155151
});
156152
});
157153

@@ -167,13 +163,12 @@ describe('copyObject', () => {
167163
expect(s3ClientMock).toHaveReceivedCommandTimes(CopyObjectCommand, 1);
168164
expect(s3ClientMock).toHaveReceivedCommandWith(CopyObjectCommand, {
169165
Bucket: bucket,
170-
CopySource: `${bucket}/${copySource}`,
166+
CopySource: `${bucket}/${copySource}?versionId=${undefined}`,
171167
Key: filePath,
172168
Metadata: undefined,
173169
MetadataDirective: MetadataDirective.COPY,
174170
Tagging: 'test=123',
175171
TaggingDirective: taggingDirective,
176-
VersionId: undefined
177172
});
178173
});
179174

@@ -190,13 +185,12 @@ describe('copyObject', () => {
190185
expect(s3ClientMock).toHaveReceivedCommandTimes(CopyObjectCommand, 1);
191186
expect(s3ClientMock).toHaveReceivedCommandWith(CopyObjectCommand, {
192187
Bucket: bucket,
193-
CopySource: `${bucket}/${copySource}`,
188+
CopySource: `${bucket}/${copySource}?versionId=1234`,
194189
Key: filePath,
195190
Metadata: undefined,
196191
MetadataDirective: MetadataDirective.COPY,
197192
Tagging: 'test=123',
198193
TaggingDirective: taggingDirective,
199-
VersionId: s3VersionId
200194
});
201195
});
202196
});

0 commit comments

Comments
 (0)