JSON Patch Request validation in Java
Asked Answered
C

4

13

In my spring boot service, I'm using https://github.com/java-json-tools/json-patch for handling PATCH requests.

Everything seems to be ok except a way to avoid modifying immutable fields like object id's, creation_time etc. I have found a similar question on Github https://github.com/java-json-tools/json-patch/issues/21 for which I could not find the right example.

This blog seems to give some interesting solutions about validating JSON patch requests with a solution in node.js. Would be good to know if something similar in JAVA is already there.

Chops answered 1/5, 2018 at 22:4 Comment(0)
M
10

Under many circumstances you can just patch an intermediate object which only has fields that the user can write to. After that you could quite easily map the intermediate object to your entity, using some object mapper or just manually.

The downside of this is that if you have a requirement that fields must be explicitly nullable, you won’t know if the patch object set a field to null explicitly or if it was never present in the patch.

What you can do too is abuse Optionals for this, e.g.

public class ProjectPatchDTO {

    private Optional<@NotBlank String> name;
    private Optional<String> description;
}

Although Optionals were not intended to be used like this, it's the most straightforward way to implement patch operations while maintaining a typed input. When the optional field is null, it was never passed from the client. When the optional is not present, that means the client has set the value to null.

Mccaskill answered 11/5, 2018 at 16:25 Comment(5)
At least it is not working with jackson 2.16 version. Optional is null in both cases. When not provided in the request and also when provided as nullVerditer
@Verditer interesting, well it's possible the ObjectMapper has a custom configuration when used in Spring Boot. I haven't tried Jackson 2.16 but we're using 2.15.4 (the default in the current Spring Boot) and it still works as I described.Mccaskill
Yes we have a custom configuration, but it is for date time handling and disable failing on unknown properties. I think this will not affect it but I am not hundred percent sure. Anyway I have used the different solution. If anybody has the same problem he can use it.Verditer
@Verditer I didn't mean you having a custom configuration, but Spring Boot having a custom configuration, as the reason being it would differentiate between null and optional.Mccaskill
Aaah ok. It is possible you are right.Verditer
K
1

Instead of receiving a JsonPatch directly from the client, define a DTO to handle the validation and then you will later convert the DTO instance to a JsonPatch.

Say you want to update a user of instance User.class, you can define a DTO such as:

public class UserDTO {

    @Email(message = "The provided email is invalid")
    private String username;

    @Size(min = 2, max = 10, message = "firstname should have at least 2 and a maximum of 10 characters")
    private String firstName;

    @Size(min = 2, max = 10, message = "firstname should have at least 2 and a maximum of 10 characters")
    private String lastName;

    @Override
    public String toString() {
        return new Gson().toJson(this);
    }

//getters and setters
}

The custom toString method ensures that fields that are not included in the update request are not prefilled with null values.

Your PATCH request can be as follows(For simplicity, I didn't cater for Exceptions)

@PatchMapping("/{id}")
    ResponseEntity<Object> updateUser(@RequestBody @Valid UserDTO request,
                                      @PathVariable String id) throws ParseException, IOException, JsonPatchException {
        User oldUser = userRepository.findById(id);
        String detailsToUpdate = request.toString();
        User newUser = applyPatchToUser(detailsToUpdate, oldUser);
        userRepository.save(newUser);
        return userService.updateUser(request, id);
    }

The following method returns the patched User which is updated above in the controller.

private User applyPatchToUser(String detailsToUpdate, User oldUser) throws IOException, JsonPatchException {
        ObjectMapper objectMapper = new ObjectMapper();
        // Parse the patch to JsonNode
        JsonNode patchNode = objectMapper.readTree(detailsToUpdate);
        // Create the patch
        JsonMergePatch patch = JsonMergePatch.fromJson(patchNode);
        // Convert the original object to JsonNode
        JsonNode originalObjNode = objectMapper.valueToTree(oldUser);
        // Apply the patch
        TreeNode patchedObjNode = patch.apply(originalObjNode);
        // Convert the patched node to an updated obj
        return objectMapper.treeToValue(patchedObjNode, User.class);
    }
Krems answered 6/1, 2022 at 20:19 Comment(1)
Your "toString" cannot distinguish between null and not provided values, which is the whole point of patch. See #38424883Lavatory
A
1

Another solution would be to imperatively deserialize and validate the request body.

So your example DTO might look like this:

public class CatDto {
    @NotBlank
    private String name;

    @Min(0)
    @Max(100)
    private int laziness;

    @Max(3)
    private int purringVolume;
}

And your controller can be something like this:

@RestController
@RequestMapping("/api/cats")
@io.swagger.v3.oas.annotations.parameters.RequestBody(
        content = @Content(schema = @Schema(implementation = CatDto.class)))
// ^^ this passes your CatDto model to swagger (you must use springdoc to get it to work!)
public class CatController {
    @Autowired
    SmartValidator validator; // we'll use this to validate our request

    @PatchMapping(path = "/{id}", consumes = "application/json")
    public ResponseEntity<String> updateCat(
            @PathVariable String id,
            @RequestBody Map<String, Object> body
            // ^^ no Valid annotation, no declarative DTO binding here!
    ) throws MethodArgumentNotValidException {
        CatDto catDto = new CatDto();
        WebDataBinder binder = new WebDataBinder(catDto);
        BindingResult bindingResult = binder.getBindingResult();

        binder.bind(new MutablePropertyValues(body));
        // ^^ imperatively bind to DTO
        body.forEach((k, v) -> validator.validateValue(CatDto.class, k, v, bindingResult));
        // ^^ imperatively validate user input
        if (bindingResult.hasErrors()) {
            throw new MethodArgumentNotValidException(null, bindingResult);
            // ^^ this can be handled by your regular exception handler
        }
        // Here you can do normal stuff with your cat DTO.
        // Map it to cat model, send to cat service, whatever.
        return ResponseEntity.ok("cat updated");
    }

}

No need for Optional's, no extra dependencies, your normal validation just works, your swagger looks good. The only problem is, you don't get proper merge patch on nested objects, but in many use cases that's not even required.

Ascendant answered 3/11, 2022 at 9:8 Comment(8)
This indeed looks interesting if you don't need nested objects, but how would it improve swagger compared to at least a somewhat typed input object? The input is just a map of object here.Mccaskill
@SebastiaanvandenBroek, great point! You need springdoc and an extra annotation to get the good looking swagger here. See my updated answer.Poche
Ahh yeah, fair enough, although that would work with any kind of solution but it's still nice to be able to override it if using Swagger. The main issue I have with this is that you still cannot actually treat the CatDTO the same way in the service as if you had received the complete object initially. Because if for example there's some field name that is null in the CatDTO, you still don't know if they intended to set that field to null or if they never passed a value for it. So for updating the db based on this, you simply don't have sufficient information (if things are nullable)Mccaskill
@SebastiaanvandenBroek, well that might be the case if you're passing Dto's to services (which I personally try to avoid). In that case you might need to overload some methods in your services to accept for example an array of updateProperties, which you can construct from keys of your Map<String, Object> body.Poche
Yeah, although the Optional 'hack' I used does that too while maintaining type safety and not having to make a manual mapping between key strings and object fields. I still think that's the easier solution if you need nullability of fields. This is very useful when you don't though.Mccaskill
Haha, honestly I like your Optional's abuse more than my solution. I wrote my solution because people on my team would never approve Optional's ("they were not intended for this kind of usage" etc.).Poche
Yeah it's not too pretty, but it's harder to introduce bugs with typed fields. Although we're soon going to refactor it all anyway since we do need nested partial objects too. Although technically that's still possible with optional objects too, but all the checking becomes cumbersome. I'm a bit surprised there's no built-in support for this with some form of type safety though. Like some tuple <Boolean, T> where the boolean indicates if the field has been set, and validation is only triggered if so.Mccaskill
O... In general, patch requests are surprisingly hard to implement correctly, with nullability, validation and swagger support, not to mention nested objects. Tried it with C# and it's not easy there too. Wondering what other people do to update objects? Get and put on client side? Patch without nullability and validation?Poche
V
0

In Spring (but not only in Spring) you can use JSONObject helper class (provided by spring) together with ObjectMapper and check if patch contains element using a has method from JSONObject class like this:

var jsonObject = new JSONObject(objectMapper.writeValueAsString(patch));
if (jsonObject.has("property")) {
    // do logic here
}

In this case you can do it for any element in json patch or merge patch no matter of element type. This solution will work not only for Spring. There are other vendors which provides JSONObject class which will do the same (org.json, etc.). And convert json patch to string is also possible with different library.

Verditer answered 27/2 at 10:33 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.