web-dev-qa-db-fra.com

Comment faire la distinction entre les valeurs nulles et non fournies pour les mises à jour partielles dans Spring Rest Controller

J'essaie de faire la distinction entre les valeurs nulles et les valeurs non fournies lors de la mise à jour partielle d'une entité avec la méthode de demande PUT dans Spring Rest Controller.

Prenons l'exemple de l'entité suivante:

@Entity
private class Person {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    /* let's assume the following attributes may be null */
    private String firstName;
    private String lastName;

    /* getters and setters ... */
}

Référentiel My Person (Spring Data):

@Repository
public interface PersonRepository extends CrudRepository<Person, Long> {
}

Le DTO que j'utilise:

private class PersonDTO {
    private String firstName;
    private String lastName;

    /* getters and setters ... */
}

Mon Spring RestController:

@RestController
@RequestMapping("/api/people")
public class PersonController {

    @Autowired
    private PersonRepository people;

    @Transactional
    @RequestMapping(path = "/{personId}", method = RequestMethod.PUT)
    public ResponseEntity<?> update(
            @PathVariable String personId,
            @RequestBody PersonDTO dto) {

        // get the entity by ID
        Person p = people.findOne(personId); // we assume it exists

        // update ONLY entity attributes that have been defined
        if(/* dto.getFirstName is defined */)
            p.setFirstName = dto.getFirstName;

        if(/* dto.getLastName is defined */)
            p.setLastName = dto.getLastName;

        return ResponseEntity.ok(p);
    }
}

Demande avec propriété manquante

{"firstName": "John"}

Comportement attendu: mise à jour firstName= "John" (laisser lastName inchangé).

Demande avec propriété null

{"firstName": "John", "lastName": null}

Comportement attendu: mise à jour firstName="John" Et mettre lastName=null.

Je ne peux pas distinguer ces deux cas, puisque lastName dans le DTO est toujours défini sur null par Jackson.

Remarque: je sais que REST meilleures pratiques (RFC 6902) recommandent d'utiliser PATCH au lieu de PUT pour les mises à jour partielles, mais dans mon scénario particulier, je dois utiliser PUT .

35
Danilo Cianciulli

En fait, si vous ignorez la validation, vous pouvez résoudre votre problème comme ceci.

   public class BusDto {
       private Map<String, Object> changedAttrs = new HashMap<>();

       /* getter and setter */
   }
  • Tout d'abord, écrivez une super classe pour votre dto, comme BusDto.
  • Deuxièmement, modifiez votre dto pour étendre la super classe, et modifiez la méthode set du dto, pour mettre le nom et la valeur de l'attribut dans la valeur changedAttrs (car le ressort invoquerait l'ensemble lorsque l'attribut a une valeur, peu importe null ou non null).
  • Troisièmement, parcourez la carte.
3
Demon Coldmist

Utilisez des drapeaux booléens comme l'auteur de jackson recommande .

class PersonDTO {
    private String firstName;
    private boolean isFirstNameDirty;

    public void setFirstName(String firstName){
        this.firstName = firstName;
        this.isFirstNameDirty = true;
    }

    public void getFirstName() {
        return firstName;
    }

    public boolean hasFirstName() {
        return isFirstNameDirty;
    }
}
10
laffuste

Une autre option consiste à utiliser Java.util.Optional.

import com.fasterxml.jackson.annotation.JsonInclude;
import Java.util.Optional;

@JsonInclude(JsonInclude.Include.NON_NULL)
private class PersonDTO {
    private Optional<String> firstName;
    private Optional<String> lastName;
    /* getters and setters ... */
}

Si firstName n'est pas défini, la valeur est null et serait ignorée par l'annotation @JsonInclude. Sinon, s'il est implicitement défini dans l'objet de demande, firstName ne serait pas nul, mais firstName.get () le serait. J'ai trouvé cette navigation sur la solution @laffuste liée à un n peu plus bas dans un commentaire différent (le commentaire initial de garretwilson disant que cela ne fonctionnait pas se révèle fonctionner).

Vous pouvez également mapper le DTO sur l'entité avec l'ObjectMapper de Jackson, et il ignorera les propriétés qui n'ont pas été transmises dans l'objet de demande:

import com.fasterxml.jackson.databind.ObjectMapper;

class PersonController {
    // ...
    @Autowired
    ObjectMapper objectMapper

    @Transactional
    @RequestMapping(path = "/{personId}", method = RequestMethod.PUT)
    public ResponseEntity<?> update(
            @PathVariable String personId,
            @RequestBody PersonDTO dto
    ) {
        Person p = people.findOne(personId);
        objectMapper.updateValue(p, dto);
        personRepository.save(p);
        // return ...
    }
}

La validation d'un DTO à l'aide de Java.util.Optional est également un peu différente. C'est documenté ici , mais il m'a fallu un certain temps pour trouver:

// ...
import javax.validation.constraints.NotNull;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
// ...
private class PersonDTO {
    private Optional<@NotNull String> firstName;
    private Optional<@NotBlank @Pattern(regexp = "...") String> lastName;
    /* getters and setters ... */
}

Dans ce cas, firstName peut ne pas être défini du tout, mais s'il est défini, il ne peut pas être défini sur null si PersonDTO est validé.

//...
import javax.validation.Valid;
//...
public ResponseEntity<?> update(
        @PathVariable String personId,
        @RequestBody @Valid PersonDTO dto
) {
    // ...
}

Il pourrait également être utile de mentionner que l'utilisation d'Optional semble être très débattue, et au moment de l'écriture, le ou les mainteneurs de Lombok ne le prendront pas en charge (voir cette question par exemple ). Cela signifie que l'utilisation de lombok.Data/lombok.Setter sur une classe avec des champs facultatifs avec des contraintes ne fonctionne pas (il tente de créer des setters avec les contraintes intactes), donc l'utilisation de @ Setter/@ Data provoque la levée d'une exception car les deux setter et la variable membre ont des contraintes définies. Il semble également préférable d'écrire le Setter sans paramètre facultatif, par exemple:

//...
import lombok.Getter;
//...
@Getter
private class PersonDTO {
    private Optional<@NotNull String> firstName;
    private Optional<@NotBlank @Pattern(regexp = "...") String> lastName;

    public void setFirstName(String firstName) {
        this.firstName = Optional.ofNullable(firstName);
    }
    // etc...
}
9
Zaahid

J'ai essayé de résoudre le même problème. J'ai trouvé assez facile d'utiliser JsonNode comme DTO. De cette façon, vous obtenez uniquement ce qui est soumis.

Vous devrez écrire vous-même un MergeService qui fait le travail réel, similaire au BeanWrapper. Je n'ai pas trouvé de cadre existant capable de faire exactement ce qui est nécessaire. (Si vous utilisez uniquement des requêtes Json, vous pourrez peut-être utiliser la méthode Jacksons readForUpdate.)

Nous utilisons en fait un autre type de nœud, car nous avons besoin des mêmes fonctionnalités que les "soumissions de formulaires standard" et d'autres appels de service. De plus, les modifications doivent être appliquées dans une transaction à l'intérieur de quelque chose appelé EntityService.

Ce MergeService deviendra malheureusement assez complexe, car vous devrez gérer vous-même les propriétés, les listes, les ensembles et les cartes :)

L'élément le plus problématique pour moi a été de faire la distinction entre les changements au sein d'un élément d'une liste/ensemble et les modifications ou remplacements de listes/ensembles.

Et la validation ne sera pas facile car vous devez valider certaines propriétés par rapport à un autre modèle (les entités JPA dans mon cas)

EDIT - Certains codes de mappage (pseudo-code):

class SomeController { 
   @RequestMapping(value = { "/{id}" }, method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE)
    @ResponseBody
    public void save(
            @PathVariable("id") final Integer id,
            @RequestBody final JsonNode modifications) {
        modifierService.applyModifications(someEntityLoadedById, modifications);
    }
}

class ModifierService {

    public void applyModifications(Object updateObj, JsonNode node)
            throws Exception {

        BeanWrapperImpl bw = new BeanWrapperImpl(updateObj);
        Iterator<String> fieldNames = node.fieldNames();

        while (fieldNames.hasNext()) {
            String fieldName = fieldNames.next();
            Object valueToBeUpdated = node.get(fieldName);
            Class<?> propertyType = bw.getPropertyType(fieldName);
            if (propertyType == null) {
               if (!ignoreUnkown) {
                    throw new IllegalArgumentException("Unkown field " + fieldName + " on type " + bw.getWrappedClass());
                }
            } else if (Map.class.isAssignableFrom(propertyType)) {
                    handleMap(bw, fieldName, valueToBeUpdated, ModificationType.MODIFY, createdObjects);
            } else if (Collection.class.isAssignableFrom(propertyType)) {
                    handleCollection(bw, fieldName, valueToBeUpdated, ModificationType.MODIFY, createdObjects);
            } else {
                    handleObject(bw, fieldName, valueToBeUpdated, propertyType, createdObjects);
            }
        }
    }
}
1
Martin Frey

Il existe une meilleure option, qui n'implique pas de changer vos DTO ou de personnaliser vos setters.

Cela implique de laisser Jackson fusionner des données avec un objet de données existant, comme suit:

MyData existingData = ...
ObjectReader readerForUpdating = objectMapper.readerForUpdating(existingData);

MyData mergedData = readerForUpdating.readValue(newData);    

Tout champ non présent dans newData n'écrasera pas les données dans existingData, mais si un champ est présent, il sera écrasé, même s'il contient null.

Code de démonstration:

    ObjectMapper objectMapper = new ObjectMapper();
    MyDTO dto = new MyDTO();

    dto.setText("text");
    dto.setAddress("address");
    dto.setCity("city");

    String json = "{\"text\": \"patched text\", \"city\": null}";

    ObjectReader readerForUpdating = objectMapper.readerForUpdating(dto);

    MyDTO merged = readerForUpdating.readValue(json);

Résulte en {"text": "patched text", "address": "address", "city": null}

Dans un contrôleur Spring Rest, vous devrez obtenir les données JSON d'origine au lieu d'avoir Spring les désérialiser pour ce faire. Modifiez donc votre point de terminaison comme ceci:

@Autowired ObjectMapper objectMapper;

@RequestMapping(path = "/{personId}", method = RequestMethod.PUT)
public ResponseEntity<?> update(
        @PathVariable String personId,
        @RequestBody JsonNode jsonNode) {

   RequestDto existingData = getExistingDataFromSomewhere();

   ObjectReader readerForUpdating = objectMapper.readerForUpdating(existingData);

   RequestDTO mergedData = readerForUpdating.readValue(jsonNode);

   ...
)
1
john16384

Probablement trop tard, mais le code suivant me permet de faire la distinction entre les valeurs nulles et non fournies

if(dto.getIban() == null){
  log.info("Iban value is not provided");
}else if(dto.getIban().orElse(null) == null){
  log.info("Iban is provided and has null value");
}else{
  log.info("Iban value is : " + dto.getIban().get());
}
0
Dragan Velkovski

Peut-être trop tard pour une réponse, mais vous pourriez:

  • Par défaut, ne désactivez pas les valeurs "nulles". Fournissez une liste explicite via les paramètres de requête des champs que vous souhaitez désactiver. De cette façon, vous pouvez toujours envoyer du JSON qui correspond à votre entité et avoir la possibilité de désactiver les champs lorsque vous en avez besoin.

  • Selon votre cas d'utilisation, certains points de terminaison peuvent traiter explicitement toutes les valeurs nulles comme des opérations non définies. Un peu dangereux pour les correctifs, mais dans certaines circonstances, peut être une option.

0
Vyacheslav