Skip to content

Commit 3acdbd9

Browse files
authored
feat: mergepatch to support partial update of nested objects (#1891)
<!-- Follow semantic-release guidelines for the PR title, which is used in the changelog. Title should follow the format `<type>(<scope>): <subject>`, where - Type is one of: build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test|BREAKING CHANGE - Scope (optional) describes the place of the change (eg a particular milestone) and is usually omitted - subject should be a non-capitalized one-line description in present imperative tense and not ending with a period See https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#-git-commit-guidelines for more details. --> ## Description As discussed in issue #954, this PR implements support for `application/merge-patch+json` type of content on requests to update endpoints. Support is added to the newer V4 endpoints for now. ## Motivation easier updates, especially of scientific metadata ## Changes: <!-- Please provide a list of the changes implemented by this PR --> * `application/merge-patch+json` content-type support * `application/json` content-type remains * in the controller function body it's checked what content-type user sends in the request, and if it's merge-patch+json, additional merge with the old document is applied * changes are applied only to the new V4 endpoints for now * for datasets, specific to ScientificMetadata and its interception, additional check is added that doesn't allow updating either value/unit without providing the second, when it was originally present in the dataset, to avoid user errors and keep functionality of the interceptor * eslint applied to jobs test files ## Tests included - [x] Included for each change/fix? - [ ] Passing? <!-- Merge will not be approved unless tests pass --> ## Documentation - [x] swagger documentation updated (required for API changes) - [ ] official documentation updated ### official documentation info <!-- If you have updated the official documentation, please provide PR # and URL of the updated pages -->
2 parents f175de8 + 961440d commit 3acdbd9

20 files changed

+1192
-462
lines changed

package-lock.json

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"@nestjs/platform-express": "^11",
4747
"@nestjs/swagger": "^11.1.1",
4848
"@nestjs/terminus": "^11",
49+
"@types/json-merge-patch": "^1.0.0",
4950
"@user-office-software/duo-logger": "^2.1.1",
5051
"@user-office-software/duo-message-broker": "^1.4.0",
5152
"ajv": "^8.12.0",
@@ -58,6 +59,7 @@
5859
"express-session": "^1.17.3",
5960
"handlebars": "^4.7.7",
6061
"js-yaml": "^4.1.0",
62+
"json-merge-patch": "^1.0.2",
6163
"jsonpath-plus": "^10.1.0",
6264
"lodash": "^4.17.21",
6365
"luxon": "^3.2.1",

src/attachments/attachments.v4.controller.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ import {
1919
import {
2020
ApiBearerAuth,
2121
ApiBody,
22+
ApiConsumes,
2223
ApiOperation,
2324
ApiParam,
2425
ApiQuery,
2526
ApiResponse,
2627
ApiTags,
2728
} from "@nestjs/swagger";
2829
import { Request } from "express";
30+
import * as jmp from "json-merge-patch";
2931
import { PoliciesGuard } from "src/casl/guards/policies.guard";
3032
import { CheckPolicies } from "src/casl/decorators/check-policies.decorator";
3133
import { AppAbility, CaslAbilityFactory } from "src/casl/casl-ability.factory";
@@ -325,13 +327,14 @@ export class AttachmentsV4Controller {
325327
@ApiOperation({
326328
summary: "It updates the attachment.",
327329
description:
328-
"It updates the attachment specified through the id specified. it updates only the specified fields.",
330+
"It updates the attachment specified through the id specified. It updates only the specified fields. Set `content-type` to `application/merge-patch+json` if you would like to update nested objects. Warning! `application/merge-patch+json` doesn’t support updating a specific item in an array — the result will always replace the entire target if it’s not an object.",
329331
})
330332
@ApiParam({
331333
name: "aid",
332334
description: "ID of the attachment to modify",
333335
type: String,
334336
})
337+
@ApiConsumes("application/json", "application/merge-patch+json")
335338
@ApiBody({
336339
type: PartialUpdateAttachmentV4Dto,
337340
})
@@ -347,14 +350,18 @@ export class AttachmentsV4Controller {
347350
@Param("aid") aid: string,
348351
@Body() updateAttachmentDto: PartialUpdateAttachmentV4Dto,
349352
): Promise<OutputAttachmentV4Dto | null> {
350-
await this.checkPermissionsForAttachment(
353+
const foundAattachment = await this.checkPermissionsForAttachment(
351354
request,
352355
aid,
353356
Action.AttachmentUpdateEndpoint,
354357
);
358+
const updateAttachmentDtoForservice =
359+
request.headers["content-type"] === "application/merge-patch+json"
360+
? jmp.apply(foundAattachment, updateAttachmentDto)
361+
: updateAttachmentDto;
355362
return this.attachmentsService.findOneAndUpdate(
356363
{ _id: aid },
357-
updateAttachmentDto,
364+
updateAttachmentDtoForservice,
358365
);
359366
}
360367

src/common/utils.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,9 +216,7 @@ export const mapScientificQuery = (
216216
/**Check if input is object or a physical quantity */
217217
const isObject = (x: unknown) => {
218218
if (
219-
x &&
220-
typeof x === "object" &&
221-
!Array.isArray(x) &&
219+
IsRecord(x) &&
222220
!(x as Record<string, unknown>).unit &&
223221
(x as Record<string, unknown>).unit !== "" &&
224222
!(x as Record<string, unknown>).u &&
@@ -229,6 +227,18 @@ const isObject = (x: unknown) => {
229227
return false;
230228
};
231229

230+
export const IsRecord = (x: unknown): x is Record<string, unknown> => {
231+
// checks if value is (nested) object
232+
return x !== null && typeof x === "object" && !Array.isArray(x);
233+
};
234+
235+
export const IsValueUnitObject = (
236+
x: unknown,
237+
): x is { value?: unknown; unit?: unknown } => {
238+
// checks if the argument is object and contains either property 'value' or 'unit'
239+
return IsRecord(x) && ("value" in x || "unit" in x);
240+
};
241+
232242
export const extractMetadataKeys = <T>(
233243
instances: T[],
234244
prop: keyof T,

src/datasets/datasets.v4.controller.ts

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ import {
1414
HttpStatus,
1515
NotFoundException,
1616
Req,
17+
BadRequestException,
1718
ForbiddenException,
1819
InternalServerErrorException,
1920
ConflictException,
2021
} from "@nestjs/common";
2122
import {
2223
ApiBearerAuth,
2324
ApiBody,
25+
ApiConsumes,
2426
ApiExtraModels,
2527
ApiOperation,
2628
ApiParam,
@@ -30,6 +32,8 @@ import {
3032
} from "@nestjs/swagger";
3133
import { Request } from "express";
3234
import { MongoError } from "mongodb";
35+
import * as jmp from "json-merge-patch";
36+
import { IsRecord, IsValueUnitObject } from "../common/utils";
3337
import { DatasetsService } from "./datasets.service";
3438
import { DatasetClass, DatasetDocument } from "./schemas/dataset.schema";
3539
import { PoliciesGuard } from "src/casl/guards/policies.guard";
@@ -236,6 +240,47 @@ export class DatasetsV4Controller {
236240
return filter;
237241
}
238242

243+
findInvalidValueUnitUpdates(
244+
updateDto: Record<string, unknown>,
245+
dataset: Record<string, unknown>,
246+
path: string[] = [],
247+
): string[] {
248+
// collect properties that have both 'value' and 'unit' in original dataset but one of either is missing in the updateDto body
249+
const unmatched: string[] = [];
250+
251+
for (const key in updateDto) {
252+
const value = updateDto[key];
253+
const currentPath = [...path, key];
254+
if (IsValueUnitObject(value)) {
255+
const datasetAtKey = currentPath.reduce<unknown>(
256+
(obj, k) => (IsRecord(obj) ? obj[k] : undefined),
257+
dataset,
258+
);
259+
if (IsValueUnitObject(datasetAtKey)) {
260+
// check if current object's 'value' or 'unit' are not undefined in original dataset and passed updateDto
261+
const originalHasValue = datasetAtKey.value !== undefined;
262+
const originalHasUnit = datasetAtKey.unit !== undefined;
263+
const updateHasValue = value.value !== undefined;
264+
const updateHasUnit = value.unit !== undefined;
265+
if (
266+
originalHasValue &&
267+
originalHasUnit &&
268+
!(updateHasValue && updateHasUnit)
269+
) {
270+
unmatched.push(currentPath.join("."));
271+
}
272+
}
273+
}
274+
// recursively go through the (scientificMetadata) object
275+
if (IsRecord(value)) {
276+
unmatched.push(
277+
...this.findInvalidValueUnitUpdates(value, dataset, currentPath),
278+
);
279+
}
280+
}
281+
return unmatched;
282+
}
283+
239284
// POST /api/v4/datasets
240285
@UseGuards(PoliciesGuard)
241286
@CheckPolicies("datasets", (ability: AppAbility) =>
@@ -670,13 +715,14 @@ export class DatasetsV4Controller {
670715
@ApiOperation({
671716
summary: "It partially updates the dataset.",
672717
description:
673-
"It updates the dataset through the pid specified. It updates only the specified fields.",
718+
"It updates the dataset through the pid specified. It updates only the specified fields. Set `content-type` to `application/merge-patch+json` if you would like to update nested objects. Warning! `application/merge-patch+json` doesn’t support updating a specific item in an array — the result will always replace the entire target if it’s not an object.",
674719
})
675720
@ApiParam({
676721
name: "pid",
677722
description: "Id of the dataset to modify",
678723
type: String,
679724
})
725+
@ApiConsumes("application/merge-patch+json", "application/json")
680726
@ApiBody({
681727
description:
682728
"Fields that needs to be updated in the dataset. Only the fields that needs to be updated have to be passed in.",
@@ -705,11 +751,30 @@ export class DatasetsV4Controller {
705751
Action.DatasetUpdate,
706752
);
707753

754+
if (foundDataset && IsRecord(updateDatasetDto) && IsRecord(foundDataset)) {
755+
const mismatchedPaths = this.findInvalidValueUnitUpdates(
756+
updateDatasetDto,
757+
foundDataset,
758+
);
759+
if (mismatchedPaths.length > 0) {
760+
throw new BadRequestException(
761+
`Original dataset ${pid} contains both value and unit in ${mismatchedPaths.join(", ")}. Please provide both when updating.`,
762+
);
763+
}
764+
} else {
765+
throw new BadRequestException(
766+
`Failed to compare scientific metadata to include both value and units`,
767+
);
768+
}
769+
770+
const updateDatasetDtoForService =
771+
request.headers["content-type"] === "application/merge-patch+json"
772+
? jmp.apply(foundDataset, updateDatasetDto)
773+
: updateDatasetDto;
708774
const updatedDataset = await this.datasetsService.findByIdAndUpdate(
709775
pid,
710-
updateDatasetDto,
776+
updateDatasetDtoForService,
711777
);
712-
713778
return updatedDataset;
714779
}
715780

src/jobs/jobs.controller.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from "@nestjs/common";
1818
import { Request } from "express";
1919
import { FilterQuery } from "mongoose";
20+
import * as jmp from "json-merge-patch";
2021
import { JobsService } from "./jobs.service";
2122
import { CreateJobDto } from "./dto/create-job.dto";
2223
import { UpdateJobDto } from "./dto/update-job.dto";
@@ -33,6 +34,7 @@ import { OutputJobV3Dto } from "./dto/output-job-v3.dto";
3334
import {
3435
ApiBearerAuth,
3536
ApiBody,
37+
ApiConsumes,
3638
ApiOperation,
3739
ApiQuery,
3840
ApiResponse,
@@ -791,12 +793,21 @@ export class JobsController {
791793
if (!canUpdate) {
792794
throw new ForbiddenException("Unauthorized to update this job.");
793795
}
796+
794797
// Allow actions to validate DTO
795798
const validateContext = { request: updateJobDto, env: process.env };
796799
await validateActions(jobConfig.update.actions, validateContext);
797800

801+
const updateJobDtoForService =
802+
request.headers["content-type"] === "application/merge-patch+json"
803+
? jmp.apply(currentJob, updateJobDto)
804+
: updateJobDto;
805+
798806
// Update job in database
799-
const updatedJob = await this.jobsService.update(id, updateJobDto);
807+
const updatedJob = await this.jobsService.update(
808+
id,
809+
updateJobDtoForService,
810+
);
800811
// Perform the action that is specified in the update portion of the job configuration
801812
if (updatedJob !== null) {
802813
await this.checkConfigVersion(jobConfig, updatedJob);
@@ -851,8 +862,10 @@ export class JobsController {
851862
@Version("4")
852863
@ApiOperation({
853864
summary: "It updates an existing job.",
854-
description: "It updates an existing job.",
865+
description:
866+
"It updates an existing job. Set `content-type` to `application/merge-patch+json` if you would like to update nested objects. Warning! `application/merge-patch+json` doesn’t support updating a specific item in an array — the result will always replace the entire target if it’s not an object.",
855867
})
868+
@ApiConsumes("application/json", "application/merge-patch+json")
856869
@ApiBody({
857870
description: "Fields for the job to be updated",
858871
required: true,

src/main.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Logger, ValidationPipe, VersioningType } from "@nestjs/common";
1010
import { ConfigService } from "@nestjs/config";
1111
import { AllExceptionsFilter, ScicatLogger } from "./loggers/logger.service";
1212
import { NestExpressApplication } from "@nestjs/platform-express";
13+
import * as bodyParser from "body-parser";
1314

1415
async function bootstrap() {
1516
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
@@ -86,7 +87,12 @@ async function bootstrap() {
8687
"maxFileUploadSizeInMb",
8788
);
8889

89-
app.useBodyParser("json", { limit: fileUploadLimitInMb });
90+
app.use(
91+
bodyParser.json({
92+
type: ["application/json", "application/merge-patch+json"],
93+
limit: fileUploadLimitInMb,
94+
}),
95+
);
9096
app.useBodyParser("urlencoded", {
9197
limit: fileUploadLimitInMb,
9298
extended: true,

test/AttachmentV4.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,46 @@ describe("Attachments v4 tests", () => {
172172
);
173173
});
174174
});
175+
176+
it("0410: should update attachment partially with nested properties with PATCH endpoint", async () => {
177+
const updatePayload = {
178+
relationships: [
179+
{
180+
targetId: "testId1-modified-twice",
181+
targetType: "sample",
182+
},
183+
{
184+
targetId: "testId2-modified-twice",
185+
targetType: "sample",
186+
},
187+
],
188+
};
189+
190+
return request(appUrl)
191+
.patch(`/api/v4/attachments/${encodeURIComponent(createdAttachmentId)}`)
192+
.set("Content-type", "application/merge-patch+json")
193+
.send(updatePayload)
194+
.auth(accessTokenAdminIngestor, { type: "bearer" })
195+
.expect(TestData.SuccessfulPatchStatusCode)
196+
.expect("Content-Type", /json/)
197+
.then((res) => {
198+
res.body.should.be.a("object");
199+
res.body.caption.should.equal("Updated caption text");
200+
res.body.thumbnail.should.equal("Updated thumbnail URL");
201+
res.body.relationships.should.deep.equal([
202+
{
203+
targetId: "testId1-modified-twice",
204+
targetType: "sample",
205+
relationType: "is attached to",
206+
},
207+
{
208+
targetId: "testId2-modified-twice",
209+
targetType: "sample",
210+
relationType: "is attached to",
211+
},
212+
]);
213+
});
214+
});
175215
});
176216

177217
describe("Delete tests", () => {

0 commit comments

Comments
 (0)