Update partially ressource with JSON Path and SpringBoot 2
Implémentation personnalisée partielle de JSON Path pour mise à jour partielle de ressource. En n'autorisant que certains attributs.
TODO : GIST & blog
REST Ressource
@RestController
@RequestMapping("/api/someResource")
public class SomeRestController {
@PatchMapping(value = "/{id}", consumes = "application/merge-patch+json")
public ResponseEntity<Object> updatePartially(@PathVariable("id") Long id, @RequestBody PartialUpdateRequestDto partialUpdate) {
try {
return ResponseEntity.ok(someService.updatePartially(id, partialUpdate));
} catch (ResourceNotFoundException e) {
return ResponseEntity.notFound().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
}
Request body + Implementation of JSON Path is enforced by enum value-objects
@Data
@NoArgsConstructor
public class PartialUpdateRequestDto {
private List<Patch> changes;
public enum AllowedPath {
//
SOME_FIELD("/someField", Boolean.class, null, false, false);
private final boolean multivalues;
private String path;
private Class type;
// For basic generic types (Ex. List<String>, type = List.class, templateValue = String.class)
private Class templateValue;
private boolean removeable;
AllowedPath(String path, Class type, Class templateValue, boolean removeable, boolean multiValues) {
this.path = path;
this.type = type;
this.templateValue = templateValue;
this.removeable = removeable;
this.multivalues = multiValues;
}
// Required for automatic swagger documentation of available paths
@JsonCreator
public AllowedPath fromPath(String path) {
return Stream.of(values())
.filter(enumPath -> enumPath.getPath().equals(path))
.findFirst().orElseThrow(() -> new IllegalArgumentException(
"Illegal Path for partial update : path=\"" + path + "\""));
}
// Required for automatic swagger documentation of available paths
@JsonValue
public String getPath() {
return path;
}
public boolean isRemoveable() {
return removeable;
}
public boolean isMultivalues() {
return multivalues;
}
public Class getType() {
return type;
}
}
/**
* @see <a href="https://tools.ietf.org/html/rfc6902">JavaScript Object Notation (JSON) Patch</a>
*/
@Data
public static class Patch {
private Operation op;
private AllowedPath path;
private List<Object> value;
@JsonCreator
private Patch(
@JsonProperty("op") Operation op,
@JsonProperty("path") AllowedPath path,
@JsonProperty("value") List<Object> value) {
if (op == null) {
throw new IllegalArgumentException("Operator is compulsory");
}
if (path == null) {
throw new IllegalArgumentException("Path is compulsory");
}
if (op.requiresValue() && (value == null || value.isEmpty())) {
throw new IllegalArgumentException("This operator requires a value");
}
this.op = op;
this.path = path;
this.value = value;
}
/**
* @see <a href="https://tools.ietf.org/html/rfc6902">JavaScript Object Notation (JSON) Patch</a>
*/
public enum Operation {
REPLACE("replace") {
@Override
public boolean requiresValue() {
return true;
}
}, REMOVE("remove") {
@Override
public boolean requiresValue() {
return false;
}
};
String name;
Operation(String name) {
this.name = name;
}
@JsonCreator
public Operation fromName(String operationName) {
return Stream.of(values())
.filter(enumOperation -> enumOperation.getName().equals(operationName))
.findFirst().orElseThrow(() -> new IllegalArgumentException(
"Illagal operation for partial update of the ressource : operation=\"" + operationName + "\""));
}
public abstract boolean requiresValue();
@JsonValue
String getName() {
return name;
}
}
}
}
// SomeServiceImpl
@Override
public List<String> updatePartially(Long id, PartialUpdateRequestDto partialUpdate) throws ResourceNotFoundException {
if (id == null) {
throw new IllegalArgumentException("Id is required to update resource");
}
if (partialUpdate == null) {
throw new IllegalArgumentException("PartialUpdate object required");
}
List<String> result = new ArrayList<>();
final Optional<SomeJpaEntity> optionalEntity = someDao.findById(id);
if (entity.isPresent()) {
final SomeJpaEntity entity = optionalEntity.get();
partialUpdate.getChanges().forEach(patch -> result.add(applyPatchOperationToEntity(entity, patch)));
someDao.save(entity);
return result;
} else {
throw new ResourceNotFoundException(id);
}
}
private String applyPatchOperationToEntity(SomeEntity entity, PartialUpdateRequestDto.Patch patch) {
switch (patch.getOp()) {
case REMOVE:
return removeValueFromEntity(entity, patch);
case REPLACE:
return replaceValueFromEntity(entity, patch);
default:
throw new IllegalArgumentException("opérateur non supporté : " + patch.getOp());
}
}
private String replaceValueFromEntity(SomeEntity entity, PartialUpdateRequestDto.Patch patch) {
try {
Object rawValue;
if (!patch.getPath().isMultivalues()) {
rawValue = patch.getValue().get(0);
} else {
rawValue = patch.getValue();
}
switch (patch.getPath()) {
case SOME_FIELD:
entity.setSomeField((Boolean) rawValue);
break;
default:
throw new IllegalArgumentException("Replacement not supported for value : " + patch.getPath());
}
return patchResultMessage(patch);
} catch (ClassCastException e) {
throw new IllegalArgumentException("Invalid type received for \"" + patch.getPath() + "\" . Expected : " + patch.getPath().getType(), e);
}
}
private String removeValueFromEntity(SomeEntity entity, PartialUpdateRequestDto.Patch patch) {
if (!patch.getPath().isRemoveable()) {
throw new IllegalArgumentException("This value cannot be removed : " + patch.getPath());
}
switch (patch.getPath()) {
case SOME_FIELD:
// Not removeable
break;
default:
throw new IllegalArgumentException("Illegal path : " + patch.getPath());
}
return patchResultMessage(patch);
}
private String patchResultMessage(PartialUpdateRequestDto.Patch patch) {
String s = "Value ";
s += patch.getPath();
s += " has been ";
s += patch.getOp();
if (patch.getOp().requiresValue()) {
s += " with : ";
s += patch.getValue();
}
return s;
}