JavaRush /Java Blog /Random-IT /API REST e convalida dei dati
Денис
Livello 37
Киев

API REST e convalida dei dati

Pubblicato nel gruppo Random-IT
Collegamento alla prima parte: API REST e attività di test successiva Bene, la nostra applicazione funziona, possiamo ottenere qualche tipo di risposta da essa, ma cosa ci dà questo? Non svolge alcun lavoro utile. Detto fatto, implementiamo qualcosa di utile. Prima di tutto aggiungiamo alcune nuove dipendenze al nostro build.gradle, ci saranno utili:
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'
E inizieremo con i dati reali che dobbiamo elaborare. Torniamo al nostro pacchetto di persistenza e iniziamo a riempire l'entità. Come ricorderete, l'abbiamo lasciato esistere con un solo campo, quindi l'auto viene generata tramite `@GeneratedValue(strategy = GenerationType.IDENTITY)` Ricordiamo le specifiche tecniche del primo capitolo:
{
  "firstName": String,
  "lastName": String,
  "department": String,
  "salary": String
  "hired": String //"yyyy-mm-dd"
  "tasks": [
  ]
}
Abbiamo abbastanza campi per la prima volta, quindi iniziamo a implementarlo. I primi tre campi non sollevano dubbi: sono righe ordinarie, ma il campo stipendio è già suggestivo. Perché la linea attuale? Nel lavoro reale, accade anche questo, un cliente viene da te e dice: voglio inviarti questo carico utile e tu lo elabori. Ovviamente puoi alzare le spalle e farlo, puoi provare a metterti d'accordo e spiegare che è meglio trasmettere i dati nel formato richiesto. Immaginiamo di esserci imbattuti in un client intelligente e di aver concordato che è meglio trasmettere i numeri in formato numerico e, poiché stiamo parlando di soldi, lascia che sia Doppio. Il parametro successivo del nostro payload sarà la data di assunzione, il cliente ce lo invierà nel formato concordato: aaaa-mm-gg, dove y è responsabile per anni, m per giorni e d è previsto per giorni - 2022- 08-12. L'ultimo campo al momento sarà un elenco di attività assegnate al cliente. Ovviamente, Task è un'altra entità nel nostro database, ma non ne sappiamo ancora molto, quindi creeremo l'entità più elementare come abbiamo fatto prima con Employee. L'unica cosa che possiamo presumere ora è che più di un compito può essere assegnato a un dipendente, quindi applicheremo il cosiddetto approccio One-To-Many, un rapporto uno-a-molti. Più specificamente, un record nella tabella dei dipendenti può corrispondere a diversi record della tabella delle attività . Ho anche deciso di aggiungere un campo uniqueNumber, in modo da poter distinguere chiaramente un dipendente da un altro. Al momento la nostra classe Employee si presenta così:
@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<>();
}
Per l'entità Task è stata creata la seguente classe:
@Entity
@Data
@Accessors(chain = true)
public class Task {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long taskId;
}
Come ho detto, non vedremo nulla di nuovo in Task, per questa classe è stato creato anche un nuovo repository, che è una copia del repository per Employee - non lo darò, puoi crearlo tu stesso per analogia. Ma ha senso parlare della classe dei Dipendenti. Come ho detto, abbiamo aggiunto diversi campi, ma ora interessa solo l'ultimo: le attività. Questo è un List<Task> task , viene immediatamente inizializzato con un ArrayList vuoto e contrassegnato con diverse annotazioni. 1. @OneToMany Come ho detto, questo sarà il nostro rapporto tra dipendenti e attività. 2. @JoinColumn : la colonna in base alla quale verranno unite le entità. In questo caso, verrà creata una colonna Employee_id nella tabella Task che punta all'ID del nostro dipendente; ci servirà come ForeighnKey Nonostante l'apparente sacralità del nome, puoi nominare la colonna come preferisci. La situazione sarà un po' più complicata se sarà necessario utilizzare non solo un ID, ma una sorta di colonna vera e propria; toccheremo questo argomento più avanti. 3. Potresti anche aver notato una nuova annotazione sopra l'id: @JsonIgnore. Poiché id è la nostra entità interna, non dobbiamo necessariamente restituirlo al client. 4. @NotBlank è un'annotazione speciale per la convalida, che dice che il campo non deve essere nullo o una stringa vuota 5. @Column(unique = true) dice che questa colonna deve avere valori univoci. Quindi abbiamo già due entità, sono addirittura collegate tra loro. È giunto il momento di integrarli nel nostro programma: occupiamoci di servizi e controller. Prima di tutto rimuoviamo il nostro stub dal metodo getAllEmployees() e trasformiamolo in qualcosa che funzioni davvero:
public List<Employee> getAllEmployees() {
       return employeeRepository.findAll();
   }
Pertanto, il nostro repository raccoglierà tutto ciò che è disponibile dal database e ce lo fornirà. È interessante notare che raccoglierà anche l'elenco delle attività. Ma rastrellarlo è certamente interessante, ma cosa significa rastrellarlo se non c’è niente? Esatto, significa che dobbiamo capire come mettere qualcosa lì. Prima di tutto, scriviamo un nuovo metodo nel nostro controller.
@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();
    }
Questo è @PostMapping, cioè elabora le richieste POST che arrivano all'endpoint dei nostri dipendenti. In generale, ho pensato che, poiché tutte le richieste a questo controller arriveranno a un endpoint, semplifichiamo un po' la cosa. Ricordi le nostre belle impostazioni in application.yml? Sistemiamoli. Lascia che la sezione dell'applicazione ora assomigli a questa:
application:
  endpoint:
    root: api/v1
    employee: ${application.endpoint.root}/employees
    task: ${application.endpoint.root}/tasks
Cosa ci dà questo? Il fatto che nel controller possiamo rimuovere la mappatura per ogni metodo specifico e l'endpoint verrà impostato a livello di classe nell'annotazione @RequestMapping ("${application.endpoint.employee}") . Questa è la bellezza ora in il nostro Titolare:
@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();
    }
}
Tuttavia, andiamo avanti. Cosa succede esattamente nel metodo createOrUpdateEmployee? Ovviamente, il nostro EmployeeService ha un metodo di salvataggio, che dovrebbe essere responsabile di tutto il lavoro di salvataggio. È anche ovvio che questo metodo può generare un'eccezione con un nome autoesplicativo. Quelli. è in corso una sorta di convalida. E la risposta dipende direttamente dai risultati della convalida, se sarà 201 Created o 400 badRequest con un elenco di cosa è andato storto. Guardando al futuro, questo è il nostro nuovo servizio di convalida, controlla i dati in arrivo per la presenza di campi obbligatori (ricordate @NotBlank?) e decide se tali informazioni sono adatte a noi o meno. Prima di passare al metodo di salvataggio, convalidiamo e implementiamo questo servizio. Per fare ciò, propongo di creare un pacchetto di convalida separato in cui inseriremo il nostro servizio.
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 si è rivelata troppo grande, ma niente panico, lo scopriremo ora :) Qui utilizziamo gli strumenti della libreria di convalida già pronta javax.validation Questa libreria ci è arrivata dalle nuove dipendenze che abbiamo aggiunto all'implementazione build.graddle 'org.springframework.boot:spring-boot-starter -validation' I nostri vecchi amici Service e RequiredArgsConstructor Ci dicono già tutto ciò che dobbiamo sapere su questa classe, c'è anche un campo di validazione finale privato. Farà la magia. Abbiamo creato il metodo isValidEmployee, nel quale possiamo passare l'entità Employee; questo metodo lancia una ValidationException, che scriveremo poco dopo. Sì, questa sarà un'eccezione personalizzata per le nostre esigenze. Utilizzando il metodo validator.validate(employee), otterremo un elenco di oggetti ConstraintViolation, tutte quelle incoerenze con le annotazioni di convalida che abbiamo aggiunto in precedenza. L'ulteriore logica è semplice, se questo elenco non è vuoto (cioè ci sono violazioni), lanciamo un'eccezione e creiamo un elenco di violazioni: il metodo buildViolationsList. Tieni presente che questo è un metodo generico, ad es. può funzionare con elenchi di violazioni di diversi oggetti: potrebbe essere utile in futuro se convalideremo qualcos'altro. Cosa fa effettivamente questo metodo? Utilizzando l'API stream, esaminiamo l'elenco delle violazioni. Trasformiamo ogni violazione nel metodo map nel nostro oggetto violazione e raccogliamo tutti gli oggetti risultanti in un elenco. Lo stiamo restituendo. Cos'altro è il nostro oggetto di violazione, chiedi? Ecco un semplice record
public record Violation(String property, String message) {}
I record sono innovazioni così speciali in Java, nel caso in cui sia necessario un oggetto con dati, senza alcuna logica o altro. Anche se io stesso non ho ancora capito il motivo per cui è stato fatto, a volte è una cosa piuttosto conveniente. Deve essere creato in un file separato, come una classe normale. Tornando alla ValidationException personalizzata, assomiglia a questa:
@RequiredArgsConstructor
public class ValidationException extends Exception {

    @Getter
    private final List<Violation> violations;
}
Memorizza un elenco di tutte le violazioni, l'annotazione Lombok - Getter è allegata all'elenco e tramite un'altra annotazione Lombok abbiamo "implementato" il costruttore richiesto :) Vale la pena notare qui che non implemento del tutto correttamente il comportamento di isValid ... metodo, restituisce true o eccezione, ma varrebbe la pena limitarci al solito False. L'approccio dell'eccezione viene adottato perché voglio restituire questo errore al client, il che significa che devo restituire qualcosa di diverso da vero o falso dal metodo booleano. Nel caso di metodi di convalida puramente interni, non è necessario generare un'eccezione; qui sarà richiesta la registrazione. Tuttavia, torniamo al nostro EmployeeService, dobbiamo ancora iniziare a salvare gli oggetti :) Vediamo come appare ora questa classe:
@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());
    }
}
Da notare la nuova proprietà finale private final ValidationService validationService; Il metodo di salvataggio stesso è contrassegnato con l'annotazione @Transactional in modo che se viene ricevuta una RuntimeException, le modifiche vengono annullate. Prima di tutto convalidiamo i dati in arrivo utilizzando il servizio che abbiamo appena scritto. Se tutto è andato liscio, controlliamo se nel database è presente un dipendente (utilizzando un numero univoco). In caso contrario, salviamo quello nuovo, se ce n'è uno, aggiorniamo i campi nella classe. Oh sì, come controlliamo effettivamente? Sì, è molto semplice, abbiamo aggiunto un nuovo metodo al repository Employee:
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
    Employee findByUniqueNumber(String uniqueNumber);
}
Cosa c'è di notevole? Non ho scritto alcuna logica o query SQL, sebbene sia disponibile qui. Spring, semplicemente leggendo il nome del metodo, determina cosa voglio: trova ByUniqueNumber e passa la stringa corrispondente al metodo. Tornando all'aggiornamento dei campi, qui ho deciso di usare il buon senso e aggiornare solo il dipartimento, lo stipendio e le mansioni, perché cambiare nome, sebbene sia una cosa accettabile, non è ancora molto comune. E la modifica della data di assunzione è generalmente una questione controversa. Cosa sarebbe bello fare qui? Combina elenchi di attività, ma poiché non abbiamo ancora attività e non sappiamo come distinguerle, lasceremo TODO. Proviamo a lanciare il nostro Frankenstein. Se non ho dimenticato di descrivere qualcosa, dovrebbe funzionare, ma prima ecco l'albero delle classi che abbiamo ottenuto: API REST e convalida dei dati - 1 le classi che sono state modificate sono evidenziate in blu, quelle nuove sono evidenziate in verde, tali indicazioni possono essere ottenute se si lavora con un repository git, ma git non è l’argomento del nostro articolo, quindi non ci soffermeremo su questo. Quindi, al momento abbiamo un endpoint che supporta due metodi GET e POST. A proposito, alcune informazioni interessanti sull'endpoint. Perché, ad esempio, non abbiamo allocato endpoint separati per GET e POST, come getAllEmployees o createEmployees? Tutto è estremamente semplice: avere un unico punto per tutte le richieste è molto più conveniente. Il routing avviene in base al metodo HTTP ed è intuitivo, non c'è bisogno di ricordare tutte le varianti di getAllEmployees, getEmployeeByName, get... aggiorna... crea... cancella... Testiamo quello che abbiamo ottenuto. Ho già scritto nell’articolo precedente che avremo bisogno di Postman, ed è ora di installarlo. Nell'interfaccia del programma creiamo una nuova richiesta POST API REST e convalida dei dati - 2 e proviamo a inviarla. Se tutto è andato bene otterremo lo Stato 201 sul lato destro dello schermo. Ma ad esempio, dopo aver inviato la stessa cosa ma senza un numero univoco (sul quale abbiamo la convalida), ottengo una risposta diversa: API REST e convalida dei dati - 3 Bene, controlliamo come funziona la nostra selezione completa: creiamo un metodo GET per lo stesso endpoint e lo inviamo . API REST e convalida dei dati - 4 Spero sinceramente che tutto abbia funzionato per te proprio come è successo per me, e ci vediamo al prossimo articolo .
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION