JavaRush /Blog Java /Random-FR /API REST et validation des données
Денис
Niveau 37
Киев

API REST et validation des données

Publié dans le groupe Random-FR
Lien vers la première partie : API REST et la prochaine tâche de test Eh bien, notre application fonctionne, nous pouvons en obtenir une sorte de réponse, mais qu'est-ce que cela nous donne ? Cela ne fait aucun travail utile. Aussitôt dit, aussitôt fait, implémentons quelque chose d'utile. Tout d'abord, ajoutons quelques nouvelles dépendances à notre build.gradle, elles nous seront utiles :
implementation 'org.apache.commons:commons-lang3:3.12.0'
implementation 'org.apache.commons:commons-collections4:4.4'
implementation 'org.springframework.boot:spring-boot-starter-validation'
Et nous commencerons par les données réelles que nous devons traiter. Revenons à notre package de persistance et commençons à remplir l'entité. Comme vous vous en souvenez, nous l'avons laissé exister avec un seul champ, puis l'auto est généré via `@GeneratedValue(strategy = GenerationType.IDENTITY)` Rappelons les spécifications techniques du premier chapitre :
{
  "firstName": String,
  "lastName": String,
  "department": String,
  "salary": String
  "hired": String //"yyyy-mm-dd"
  "tasks": [
  ]
}
Nous avons suffisamment de champs pour la première fois, alors commençons à l'implémenter. Les trois premiers champs ne posent pas de questions - ce sont des lignes ordinaires, mais le champ salarial est déjà suggestif. Pourquoi cette ligne réelle ? Dans le travail réel, cela arrive également, un client vient vers vous et vous dit : je veux vous envoyer cette charge utile, et vous la traitez. Vous pouvez bien sûr hausser les épaules et le faire, vous pouvez essayer de vous mettre d'accord et expliquer qu'il est préférable de transmettre les données dans le format requis. Imaginons que nous tombions sur un client intelligent et convenions qu'il est préférable de transmettre des nombres au format numérique, et puisque nous parlons d'argent, que ce soit le double. Le prochain paramètre de notre charge utile sera la date d'embauche, le client nous l'enverra dans le format convenu : aaaa-mm-jj, où y est responsable des années, m des jours et d est attendu des jours - 2022- 08-12. Le dernier champ pour le moment sera une liste de tâches assignées au client. Évidemment, Task est une autre entité de notre base de données, mais nous n’en savons pas encore grand-chose, nous allons donc créer l’entité la plus basique comme nous l’avons fait auparavant avec Employee. La seule chose que nous pouvons supposer maintenant est que plus d'une tâche peut être assignée à un seul employé. Nous appliquerons donc l'approche dite un-à-plusieurs, un ratio un-à-plusieurs. Plus précisément, un enregistrement de la table des employés peut correspondre à plusieurs enregistrements de la table des tâches . J'ai également décidé d'ajouter un champ uniqueNumber, afin que nous puissions clairement distinguer un employé d'un autre. Pour le moment, notre classe Employee ressemble à ceci :
@Entity
@Data
@Accessors(chain = true)
public class Employee {

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

    @NotBlank
    @Column(unique = true)
    private String uniqueNumber;
    private String firstName;
    private String lastName;
    private String department;
    private Double salary;
    private LocalDate hired;

    @OneToMany
    @JoinColumn(name = "employee_id")
    List<Task> tasks = new ArrayList<>();
}
La classe suivante a été créée pour l'entité Task :
@Entity
@Data
@Accessors(chain = true)
public class Task {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long taskId;
}
Comme je l'ai dit, nous ne verrons rien de nouveau dans Task, un nouveau référentiel a également été créé pour cette classe, qui est une copie du référentiel pour Employee - je ne le donnerai pas, vous pouvez le créer vous-même par analogie. Mais il est logique de parler de la classe Employee. Comme je l'ai dit, nous avons ajouté plusieurs champs, mais seul le dernier nous intéresse désormais : les tâches. Il s'agit d'une List<Task> tâches , elle est immédiatement initialisée avec une ArrayList vide et marquée de plusieurs annotations. 1. @OneToMany Comme je l'ai dit, ce sera notre ratio employés/tâches. 2. @JoinColumn - la colonne par laquelle les entités seront jointes. Dans ce cas, une colonne Employee_id sera créée dans la table Task pointant vers l'identifiant de notre employé ; elle nous servira de ForeighnKey Malgré le caractère apparemment sacré du nom, vous pouvez nommer la colonne comme vous le souhaitez. La situation sera un peu plus compliquée si vous devez utiliser non seulement un identifiant, mais une sorte de véritable colonne ; nous aborderons ce sujet plus tard. 3. Vous avez peut-être également remarqué une nouvelle annotation au-dessus de l'identifiant - @JsonIgnore. Puisque l’identifiant est notre entité interne, nous n’avons pas nécessairement besoin de le restituer au client. 4. @NotBlank est une annotation spéciale pour la validation, qui indique que le champ ne doit pas être nul ni une chaîne vide. 5. @Column(unique = true) indique que cette colonne doit avoir des valeurs uniques. Nous avons donc déjà deux entités, elles sont même connectées entre elles. Le moment est venu de les intégrer dans notre programme - passons aux services et aux contrôleurs. Tout d’abord, supprimons notre stub de la méthode getAllEmployees() et transformons-le en quelque chose qui fonctionne réellement :
public List<Employee> getAllEmployees() {
       return employeeRepository.findAll();
   }
Ainsi, notre référentiel récupérera tout ce qui est disponible dans la base de données et nous le donnera. Il est à noter qu'il récupérera également la liste des tâches. Mais le ratisser est certainement intéressant, mais qu’est-ce que le ratisser s’il n’y a rien là ? C'est vrai, cela signifie que nous devons trouver comment y mettre quelque chose. Tout d’abord, écrivons une nouvelle méthode dans notre contrôleur.
@PostMapping("${application.endpoint.employee}")
    public ResponseEntity<?> createOrUpdateEmployee(@RequestBody final Employee employee) {
        try {
            employeeService.save(employee);
        } catch (ValidationException e) {
            return ResponseEntity.badRequest().body(e.getViolations());
        }
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }
C'est @PostMapping, c'est-à-dire il traite les requêtes POST arrivant au point final de nos employés. En général, je pensais que puisque toutes les requêtes adressées à ce contrôleur aboutiraient à un seul point final, simplifions cela un peu. Vous vous souvenez de nos jolis paramètres dans application.yml ? Réparons-les. Laissez la section d'application ressembler maintenant à ceci :
application:
  endpoint:
    root: api/v1
    employee: ${application.endpoint.root}/employees
    task: ${application.endpoint.root}/tasks
Qu'est-ce que cela nous donne ? Le fait que dans le contrôleur, nous pouvons supprimer le mappage pour chaque méthode spécifique et que le point de terminaison sera défini au niveau de la classe dans l' annotation @RequestMapping("${application.endpoint.employee}") . C'est la beauté maintenant notre contrôleur :
@RestController
@RequestMapping("${application.endpoint.employee}")
@RequiredArgsConstructor
public class EmployeeController {

    private final EmployeeService employeeService;

    @GetMapping
    public ResponseEntity<List<Employee>> getEmployees() {
        return ResponseEntity.ok().body(employeeService.getAllEmployees());
    }

    @PostMapping
    public ResponseEntity<?> createOrUpdateEmployee(@RequestBody final Employee employee) {
        try {
            employeeService.save(employee);
        } catch (ValidationException e) {
            return ResponseEntity.badRequest().body(e.getViolations());
        }
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }
}
Cependant, passons à autre chose. Que se passe-t-il exactement dans la méthode createOrUpdateEmployee ? De toute évidence, notre EmployeeService dispose d'une méthode de sauvegarde, qui devrait être responsable de tout le travail de sauvegarde. Il est également évident que cette méthode peut lever une exception avec un nom explicite. Ceux. une sorte de validation est en cours. Et la réponse dépend directement des résultats de validation, qu'il s'agisse de 201 Created ou de 400 badRequest avec une liste de ce qui n'a pas fonctionné. Pour l'avenir, il s'agit de notre nouveau service de validation, il vérifie les données entrantes pour la présence des champs obligatoires (vous vous souvenez de @NotBlank ?) et décide si ces informations nous conviennent ou non. Avant de passer à la méthode de sauvegarde, validons et implémentons ce service. Pour ce faire, je propose de créer un package de validation distinct dans lequel nous mettrons notre service.
import com.example.demo.exception.ValidationException;
import com.example.demo.persistence.entity.Employee;
import lombok.RequiredArgsConstructor;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Set;

import javax.validation.ConstraintViolation;
import javax.validation.Validator;

@Service
@RequiredArgsConstructor
public class ValidationService {

    private final Validator validator;

    public boolean isValidEmployee(Employee employee) throws ValidationException {
        Set<Constraintviolation<Employee>> constraintViolations = validator.validate(employee);

        if (CollectionUtils.isNotEmpty(constraintViolations)) {
            throw new ValidationException(buildViolationsList(constraintViolations));
        }
        return true;
    }

    private <T> List<Violation> buildViolationsList(Set<Constraintviolation<T>> constraintViolations) {
        return constraintViolations.stream()
                                   .map(violation -> new Violation(
                                                   violation.getPropertyPath().toString(),
                                                   violation.getMessage()
                                           )
                                   )
                                   .toList();
    }
}
La classe s'est avérée trop grande, mais pas de panique, nous allons le découvrir maintenant :) Ici, nous utilisons les outils de la bibliothèque de validation prête à l'emploi javax.validation Cette bibliothèque nous est venue des nouvelles dépendances que nous ajouté à l'implémentation build.graddle 'org.springframework.boot:spring-boot-starter -validation' Nos vieux amis Service et RequiredArgsConstructor nous disent déjà tout ce que nous devons savoir sur cette classe, il existe également un champ de validation final privé. Il fera la magie. Nous avons créé la méthode isValidEmployee, dans laquelle nous pouvons passer l'entité Employee ; cette méthode lève une ValidationException, que nous écrirons un peu plus tard. Oui, ce sera une exception personnalisée pour nos besoins. En utilisant la méthode validator.validate(employee), nous obtiendrons une liste d'objets ConstraintViolation - toutes ces incohérences avec les annotations de validation que nous avons ajoutées précédemment. La logique supplémentaire est simple, si cette liste n'est pas vide (c'est-à-dire qu'il y a des violations), nous levons une exception et construisons une liste de violations - la méthode buildViolationsList. Veuillez noter qu'il s'agit d'une méthode générique, c'est-à-dire peut travailler avec des listes de violations de différents objets - cela peut être utile à l'avenir si nous validons autre chose. Concrètement, à quoi sert cette méthode ? À l'aide de l'API stream, nous parcourons la liste des violations. Nous transformons chaque violation de la méthode map en notre propre objet de violation et rassemblons tous les objets résultants dans une liste. Nous le rendons. Quel autre est notre propre objet de violation, demandez-vous ? Voici un enregistrement simple
public record Violation(String property, String message) {}
Les enregistrements sont des innovations très spéciales en Java, au cas où vous auriez besoin d'un objet avec des données, sans aucune logique ni autre chose. Même si je n’ai pas encore compris pourquoi cela a été fait, c’est parfois une chose assez pratique. Il doit être créé dans un fichier séparé, comme une classe ordinaire. Revenant à l'exception ValidationException personnalisée, elle ressemble à ceci :
@RequiredArgsConstructor
public class ValidationException extends Exception {

    @Getter
    private final List<Violation> violations;
}
Il stocke une liste de toutes les violations, l'annotation Lombok - Getter est attachée à la liste, et via une autre annotation Lombok, nous avons « implémenté » le constructeur requis :) Il convient de noter ici que je n'implémente pas tout à fait correctement le comportement de isValid ... méthode, elle renvoie soit vrai, soit exception, mais cela vaudrait la peine de se limiter à l'habituel False. L'approche d'exception est effectuée parce que je souhaite renvoyer cette erreur au client, ce qui signifie que je dois renvoyer quelque chose d'autre que vrai ou faux à partir de la méthode booléenne. Dans le cas de méthodes de validation purement internes, il n'est pas nécessaire de lever une exception ; une journalisation sera ici requise. Cependant, revenons à notre EmployeeService, nous devons encore commencer à sauvegarder les objets :) Voyons à quoi ressemble cette classe maintenant :
@Service
@RequiredArgsConstructor
public class EmployeeService {

    private final EmployeeRepository employeeRepository;
    private final ValidationService validationService;

    public List<Employee> getAllEmployees() {
        return employeeRepository.findAll();
    }

    @Transactional
    public void save(Employee employee) throws ValidationException {
        if (validationService.isValidEmployee(employee)) {
            Employee existingEmployee = employeeRepository.findByUniqueNumber(employee.getUniqueNumber());
            if (existingEmployee == null) {
                employeeRepository.save(employee);
            } else {
                existingEmployee = updateFields(existingEmployee, employee);
                employeeRepository.save(existingEmployee);
            }
        }
    }

    private Employee updateFields(Employee existingEmployee, Employee updatedEmployee) {
        return existingEmployee.setDepartment(updatedEmployee.getDepartment())
                               .setSalary(updatedEmployee.getSalary())
            				 //TODO implement tasks merging instead of replacement
                               .setTasks(updatedEmployee.getTasks());
    }
}
Notez la nouvelle propriété finale private final ValidationService validationService ; La méthode de sauvegarde elle-même est marquée de l'annotation @Transactional afin que si une RuntimeException est reçue, les modifications sont annulées. Tout d'abord, nous validons les données entrantes en utilisant le service que nous venons d'écrire. Si tout s'est bien passé, nous vérifions s'il y a un employé existant dans la base de données (à l'aide d'un numéro unique). Sinon, on sauvegarde le nouveau, s'il y en a un, on met à jour les champs dans la classe. Oh oui, comment pouvons-nous réellement vérifier ? Oui, c'est très simple, nous avons ajouté une nouvelle méthode au référentiel Employé :
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
    Employee findByUniqueNumber(String uniqueNumber);
}
Qu'est-ce qui est remarquable ? Je n'ai écrit aucune logique ni requête SQL, bien que cela soit disponible ici. Spring, simplement en lisant le nom de la méthode, détermine ce que je veux - recherchez ByUniqueNumber et transmettez la chaîne correspondante à la méthode. Revenons à la mise à jour des champs - ici, j'ai décidé de faire preuve de bon sens et de mettre à jour uniquement le département, le salaire et les tâches, car changer le nom, bien que ce soit une chose acceptable, n'est toujours pas très courant. Et changer la date d’embauche est généralement une question controversée. Qu'est-ce qu'il y aurait de bien à faire ici ? Combinez les listes de tâches, mais comme nous n'avons pas encore de tâches et ne savons pas comment les distinguer, nous quitterons TODO. Essayons de lancer notre Frankenstein. Si je n'ai rien oublié de décrire, cela devrait fonctionner, mais d'abord, voici l'arbre des classes que nous avons : API REST et validation des données - 1 Les classes qui ont été modifiées sont surlignées en bleu, les nouvelles sont surlignées en vert, de telles indications peuvent être obtenues en travaillant avec un dépôt git, mais git n'est pas le sujet de notre article, nous ne nous y attarderons donc pas. Ainsi, pour le moment, nous avons un point de terminaison qui prend en charge deux méthodes GET et POST. Au fait, quelques informations intéressantes sur le point final. Pourquoi, par exemple, n'avons-nous pas alloué de points de terminaison distincts pour GET et POST, tels que getAllEmployees ou createEmployees ? Tout est extrêmement simple : avoir un point unique pour toutes les demandes est bien plus pratique. Le routage s'effectue sur la base de la méthode HTTP et il est intuitif, il n'est pas nécessaire de mémoriser toutes les variantes de getAllEmployees, getEmployeeByName, get... update... create... delete... Testons ce que nous avons obtenu. J'ai déjà écrit dans l'article précédent que nous aurons besoin de Postman et qu'il est temps de l'installer. Dans l'interface du programme, nous créons une nouvelle requête POST API REST et validation des données - 2 et essayons de l'envoyer. Si tout s'est bien passé, nous obtiendrons le statut 201 sur le côté droit de l'écran. Mais par exemple, après avoir envoyé la même chose mais sans numéro unique (sur lequel nous avons une validation), j'obtiens une réponse différente : Eh API REST et validation des données - 3 bien, vérifions comment fonctionne notre sélection complète - nous créons une méthode GET pour le même point final et l'envoyons . API REST et validation des données - 4 J'espère sincèrement que tout s'est bien passé pour vous comme pour moi, et à bientôt dans le prochain article .
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION