web-dev-qa-db-fra.com

Méthode Spring MVC PATCH: mises à jour partielles

J'ai un projet où j'utilise Spring MVC + Jackson pour créer un service REST. Disons que j'ai l'entité Java suivante

public class MyEntity {
    private Integer id;
    private boolean aBoolean;
    private String aVeryBigString;
    //getter & setters
}

Parfois, je veux juste mettre à jour la valeur booléenne et je ne pense pas que l'envoi de l'objet entier avec sa grosse chaîne soit une bonne idée, mais simplement de mettre à jour un booléen simple. J'ai donc envisagé d'utiliser la méthode HTTP PATCH pour n'envoyer que les champs devant être mis à jour. Donc, je déclare la méthode suivante dans mon contrôleur:

@RequestMapping(method = RequestMethod.PATCH)
public void patch(@RequestBody MyVariable myVariable) {
    //calling a service to update the entity
}

Le problème est le suivant: comment savoir quels champs doivent être mis à jour? Par exemple, si le client veut juste mettre à jour le booléen, j'obtiendrai un objet avec un "aVeryBigString" vide. Comment suis-je censé savoir que l'utilisateur veut seulement mettre à jour le booléen, mais ne veut pas vider la chaîne? 

J'ai "résolu" le problème en construisant des URL personnalisées. Par exemple, l'URL suivante: POST/myentities/1/aboolean/true sera mappée sur une méthode permettant uniquement de mettre à jour le booléen. Le problème avec cette solution est qu’elle n’est pas conforme à REST. Je ne veux pas être à 100% conforme REST, mais je ne me sens pas à l'aise de fournir une URL personnalisée pour mettre à jour chaque champ (d'autant plus que cela pose des problèmes lorsque je souhaite mettre à jour plusieurs champs).

Une autre solution consisterait à scinder "MyEntity" en plusieurs ressources et à mettre à jour ces ressources, mais j’ai l’impression que cela n’a aucun sens: "MyEntity" est une ressource simple, il n’est pas composé de other Ressources.

Alors, existe-t-il un moyen élégant de résoudre ce problème?

41
mael

Cela pourrait être très tardif, mais pour le bien des débutants et des personnes qui rencontrent le même problème, laissez-moi vous partager ma propre solution.

Dans mes projets antérieurs, pour simplifier les choses, j'utilise simplement la carte Java native. Il capturera toutes les nouvelles valeurs, y compris les valeurs NULL que le client a explicitement définies sur NULL. À ce stade, il sera facile de déterminer quelles propriétés Java doivent être définies sur null. Contrairement à l'utilisation du même POJO en tant que modèle de domaine, vous ne pourrez pas distinguer les champs définis par le client sur null. qui ne sont tout simplement pas inclus dans la mise à jour mais qui par défaut seront nuls.

De plus, vous devez demander à la demande http d'envoyer l'ID de l'enregistrement que vous souhaitez mettre à jour et ne l'incluez pas dans la structure de données du correctif. Ce que j’ai fait, c’est de définir l’ID dans l’URL en tant que variable de chemin et les données de correctif en tant que corps PATCH. Ensuite, avec l’ID, vous obtiendrez l’enregistrement via un modèle de domaine, puis avec HashMap, service ou utilitaire de mappage pour appliquer les modifications au modèle de domaine concerné.

Mettre à jour

Vous pouvez créer une superclasse abstraite pour vos services avec ce type de code générique. Vous devez utiliser Java Generics. Ceci n’est qu’un segment de la mise en œuvre possible, j’espère que vous aurez l’idée. Il est également préférable d’utiliser un cadre de mappage tel que Orika ou Dozer.

public abstract class AbstractService<Entity extends BaseEntity, DTO extends BaseDto> {
    @Autowired
    private MapperService mapper;

    @Autowired
    private BaseRepo<Entity> repo;

    private Class<DTO> dtoClass;

    private Class<Entity> entityCLass;

    public AbstractService(){
       entityCLass = (Class<Entity>) SomeReflectionTool.getGenericParameter()[0];
       dtoClass = (Class<DTO>) SomeReflectionTool.getGenericParameter()[1];
    }

    public DTO patch(Long id, Map<String, Object> patchValues) {
        Entity entity = repo.get(id);
        DTO dto = mapper.map(entity, dtoClass);
        mapper.map(patchValues, dto);
        Entity updatedEntity = toEntity(dto);
        save(updatedEntity);
        return dto;
    }
}
16
vine

La manière correcte de procéder est la méthode proposée dans JSON PATCH RFC 6902

Un exemple de demande serait:

PATCH http://example.com/api/entity/1 HTTP/1.1
Content-Type: application/json-patch+json 

[
  { "op": "replace", "path": "aBoolean", "value": true }
]
10
Chexpir

L’intérêt de PATCH est que vous envoyez pas la représentation complète de l’entité, je ne comprends donc pas vos commentaires sur la chaîne vide. Vous devez gérer une sorte de JSON simple tel que:

{ aBoolean: true }

et l'appliquer à la ressource spécifiée. L'idée est que ce qui a été reçu est un diff de l'état de la ressource souhaitée et de l'état de la ressource en cours.

4
Tom G

Spring utilise/ne peut pas utiliser PATCH pour corriger votre objet en raison du même problème que vous avez déjà: Le désérialiseur JSON crée un POJO Java avec des champs nullés.

Cela signifie que vous devez fournir votre propre logique pour patcher une entité (c'est-à-dire uniquement lorsque vous utilisez PATCH mais pas POST).

Soit vous savez que vous utilisez uniquement des types non primitifs, soit des règles (String est vide, c'est null, ce qui ne fonctionne pas pour tout le monde), soit vous devez fournir un paramètre supplémentaire qui définit les valeurs remplacées. Le dernier fonctionne parfaitement pour moi: l’application JavaScript sait quels champs ont été modifiés et envoyés en plus du corps JSON de cette liste au serveur. Par exemple, si un champ description a été nommé pour changer (patch) mais n'est pas indiqué dans le corps JSON, il était annulé.

3
knalli

Après avoir fouillé un peu, j'ai trouvé une solution acceptable en utilisant la même solution que celle utilisée par un Spring MVC DomainObjectReader voir aussi: JsonPatchHandler

@RepositoryRestController
public class BookCustomRepository {
    private final DomainObjectReader domainObjectReader;
    private final ObjectMapper mapper;

    private final BookRepository repository;


    @Autowired
    public BookCustomRepository(BookRepository bookRepository, 
                                ObjectMapper mapper,
                                PersistentEntities persistentEntities,
                                Associations associationLinks) {
        this.repository = bookRepository;
        this.mapper = mapper;
        this.domainObjectReader = new DomainObjectReader(persistentEntities, associationLinks);
    }


    @PatchMapping(value = "/book/{id}", consumes = {MediaType.APPLICATION_JSON_UTF8_VALUE, MediaType.APPLICATION_JSON_VALUE})
    public ResponseEntity<?> patch(@PathVariable String id, ServletServerHttpRequest request) throws IOException {

        Book entityToPatch = repository.findById(id).orElseThrow(ResourceNotFoundException::new);
        Book patched = domainObjectReader.read(request.getBody(), entityToPatch, mapper);
        repository.save(patched);

        return ResponseEntity.noContent().build();
    }

}
3
snovelli

Ne pourriez-vous pas simplement envoyer un objet contenant les champs mis à jour?

Appel de script:

var data = JSON.stringify({
                aBoolean: true
            });
$.ajax({
    type: 'patch',
    contentType: 'application/json-patch+json',
    url: '/myentities/' + entity.id,
    data: data
});

Contrôleur MVC à ressort:

@PatchMapping(value = "/{id}")
public ResponseEntity<?> patch(@RequestBody Map<String, Object> updates, @PathVariable("id") String id)
{
    // updates now only contains keys for fields that was updated
    return ResponseEntity.ok("resource updated");
}

Dans le membre path du contrôleur, parcourez les paires clé/valeur de la mappe updates. Dans l'exemple ci-dessus, la clé "aBoolean" contiendra la valeur true. L'étape suivante consistera à attribuer les valeurs en appelant les opérateurs d'entité. Cependant, c'est un type de problème différent.

1
Axel Goethe

Vous pouvez utiliser Optional<> pour cela:

public class MyEntityUpdate {
    private Optional<String> aVeryBigString;
}

De cette façon, vous pouvez inspecter l'objet de mise à jour comme suit:

if(update.getAVeryBigString() != null)
    entity.setAVeryBigString(update.getAVeryBigString().get());

Si le champ aVeryBigString ne figure pas dans le document JSON, le champ POJO aVeryBigString sera null. S'il se trouve dans le document JSON, mais avec une valeur null, le champ POJO sera une Optional avec une valeur enveloppée null. Cette solution vous permet de différencier les cas "non mis à jour" et les cas "définis à null".

1
Mickael Marrache

J'ai corrigé le problème comme ceci, parce que je ne peux pas changer le service

public class Test {

void updatePerson(Person person,PersonPatch patch) {

    for (PersonPatch.PersonPatchField updatedField : patch.updatedFields) {
        switch (updatedField){

            case firstname:
                person.setFirstname(patch.getFirstname());
                continue;
            case lastname:
                person.setLastname(patch.getLastname());
                continue;
            case title:
                person.setTitle(patch.getTitle());
                continue;
        }

    }

}

public static class PersonPatch {

    private final List<PersonPatchField> updatedFields = new ArrayList<PersonPatchField>();

    public List<PersonPatchField> updatedFields() {
        return updatedFields;
    }

    public enum PersonPatchField {
        firstname,
        lastname,
        title
    }

    private String firstname;
    private String lastname;
    private String title;

    public String getFirstname() {
        return firstname;
    }

    public void setFirstname(final String firstname) {
        updatedFields.add(PersonPatchField.firstname);
        this.firstname = firstname;
    }

    public String getLastname() {
        return lastname;
    }

    public void setLastname(final String lastname) {
        updatedFields.add(PersonPatchField.lastname);
        this.lastname = lastname;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(final String title) {
        updatedFields.add(PersonPatchField.title);
        this.title = title;
    }
}

Jackson a appelé seulement quand les valeurs existent .. Vous pouvez donc sauvegarder quel setter a été appelé.

0
kaytastrophe

Voici une implémentation pour une commande de patch utilisant googles GSON.

package de.tef.service.payment;

import com.google.gson.*;

class JsonHelper {
    static <T> T patch(T object, String patch, Class<T> clazz) {
        JsonElement o = new Gson().toJsonTree(object);
        JsonObject p = new JsonParser().parse(patch).getAsJsonObject();
        JsonElement result = patch(o, p);
        return new Gson().fromJson(result, clazz);
    }

    static JsonElement patch(JsonElement object, JsonElement patch) {
        if (patch.isJsonArray()) {
            JsonArray result = new JsonArray();
            object.getAsJsonArray().forEach(result::add);
            return result;
        } else if (patch.isJsonObject()) {
            System.out.println(object + " => " + patch);
            JsonObject o = object.getAsJsonObject();
            JsonObject p = patch.getAsJsonObject();
            JsonObject result = new JsonObject();
            o.getAsJsonObject().entrySet().stream().forEach(e -> result.add(e.getKey(), p.get(e.getKey()) == null ? e.getValue() : patch(e.getValue(), p.get(e.getKey()))));
            return result;
        } else if (patch.isJsonPrimitive()) {
            return patch;
        } else if (patch.isJsonNull()) {
            return patch;
        } else {
            throw new IllegalStateException();
        }
    }
}

L'implémentation est récursive pour prendre en compte les structures imbriquées. Les tableaux ne sont pas fusionnés, car ils ne possèdent pas de clé pour la fusion.

Le "patch" JSON est directement converti de String en JsonElement et non en un objet pour séparer les champs non renseignés des champs remplis avec NULL.

0
Thomas Neeb