JavaRush /Java блог /Random UA /REST API та Валідація даних
Денис
37 рівень
Киев

REST API та Валідація даних

Стаття з групи Random UA
Посилання на першу частину: REST API та чергове тестове завдання Ну що ж, ось наш додаток і працює, від нього можна домогтися якоїсь відповіді, але що це нам дає? Жодної корисної роботи воно не виконує. Сказано – зроблено, давайте реалізуємо щось корисне. Насамперед додамо в наш build.gradle кілька нових залежностей, вони нам знадобляться:
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'
А почнемо ми власне з даних, які ми повинні обробляти. Повернемося до нашого пакету persistence і займемося наповненням сутності. Як ви пам'ятаєте, ми залишабо її існувати тільки з одним полем, і авто генерується через `@GeneratedValue(strategy = GenerationType.IDENTITY)` Згадаймо ТЗ з першого розділу:
{
  "firstName": String,
  "lastName": String,
  "department": String,
  "salary": String
  "hired": String //"yyyy-mm-dd"
  "tasks": [
  ]
}
Спочатку полів нам вистачить, тому почнемо реалізовувати. Перші три поля питань не викликають – це звичайні рядки, а от поле зарплата вже наводить на думку. Чому власне рядок? У реальній роботі таке теж трапляється, приходить до тебе замовник і каже - хочу тобі посилати ось такий ось payload, а ти його вже обробляй. Можна, звичайно, знизати плечима і робити, можна спробувати домовитися і пояснити, що дані краще передавати в потрібному форматі. Давайте уявимо, що клієнт нам попався розумний і погодився, що числа краще передавати в числовому форматі, а якщо йдеться про гроші, нехай це буде Double. Наступним параметром нашого payload буде дата найму, клієнт буде її посилати в обумовленому форматі: yyyy-mm-dd, де y відповідає за роки, m за дні, ну а d очікувано за дні - 2022-08-12. Останнім на даний момент полем буде список завдань, призначених на клієнта. Очевидно, що Завдання це ще одна сутність у нашій базі даних, але ми поки що знаємо про неї не багато, тому створимо саму базову сутність як ми раніше робабо з Employee. Єдине, що ми можемо зараз припустити, це те, що на одного співробітника може бути призначено більше одного завдання, тому ми застосуємо так званий One-To-Many підхід, співвідношення одне до багатьох. Якщо говорити предметно, то одного запису таблиці employee може відповідати кілька записів з таблиці tasks . Я так само вирішив додати таку штуку як поле uniqueNumber, ну що б ми могли чітко відрізняти одного співробітника від іншого. На даний момент наш клас Employee виглядає так:
@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<>();
}
Для сутності Task був створений такий клас:
@Entity
@Data
@Accessors(chain = true)
public class Task {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long taskId;
}
Як я й казав - нічого нового в Task ми не побачимо, так само для цього класу був створений новий репозиторій, що є калькою з репозиторію для Employee - його я наводити не буду, ви можете і самі створити за аналогією. А ось про клас Employee поговорити є сенс. Як я й казав - ми додали кілька полів, але інтерес представляє зараз лише останнє з них - tasks. Це List<Task> tasks , він відразу ініціалізований порожнім ArrayList і позначений декількома інструкціями. 1. @OneToMany Як я й казав, це буде наше співвідношення службовців та завдань. 2. @JoinColumn - та колонка за якою сутності будуть об'єднані. В даному випадку, в таблиці Task буде створена колонка employee_id вказує на id нашого службовця, вона буде служити нам ForeighnKey Не дивлячись на сакральність імені, що здається, - назвати колонку можна як завгодно. Трохи складніше справа буде, якщо вам знадобиться використовувати не просто ID, а якусь реальну колонку, цієї теми ми торкнемося пізніше. 3. Також ви могли помітити нову інструкцію над id - @JsonIgnore. Оскільки id це наша внутрішня сутність, то нам зовсім не обов'язково повертати її клієнту. 4. @NotBlank це спеціальна анотація для валідації, яка говорить, що поле не повинно бути null або порожнім рядком 5. @Column(unique = true) каже, що ця колонка повинна мати унікальні значення. Отже, ми вже маємо дві сутності, вони навіть між собою пов'язані. Настав час інтегрувати їх у нашу програму - йдемо займатися сервісами та контролерами. Насамперед, приберемо нашу заглушку з методу getAllEmployees() і перетворимо його на реально працюючий:
public List<Employee> getAllEmployees() {
       return employeeRepository.findAll();
   }
Таким чином, наш репозиторій буде вигрібати з бази все що є і віддавати нам. Примітно, що список завдань він теж підхопить. Але вигрібати це, звичайно, цікаво, а що вигрібати, якщо там нічого не лежить? Правильно, значить, нам потрібно придумати як туди щось класти. Насамперед - напишемо новий метод у нашому контролері.
@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();
    }
Це @ PostMapping, тобто. він обробляє POST запити, що прийшли на наш ендпоінт employees. Взагалі я так подумав, що якщо вже у нас усі запити в цей контролер будуть приходити на один ендпоінт - давайте це трохи спростимо. Пам'ятаєте наші славні налаштування в application.yml? Ось їх і виправимо. Нехай тепер секція application виглядає так:
application:
  endpoint:
    root: api/v1
    employee: ${application.endpoint.root}/employees
    task: ${application.endpoint.root}/tasks
Що це нам дає? Те, що в контролері ми можемо прибрати мапінг для кожного конкретного методу, а ендпоінт буде заданий на рівні класу в анотації @RequestMapping("${application.endpoint.employee}") Ось яка краса тепер у нашому Контролері:
@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();
    }
}
Однак рухаємось далі. Що взагалі відбувається у методі createOrUpdateEmployee? Очевидно, що наш робітникобслуговується методом захисту, який і повинен відповідати за всю роботу зі збереження. Очевидно також і те, що цей метод може викидати виняток з назвою, що говорить. Тобто. проводиться якась валідація. А відповідь залежить безпосередньо від результатів валідації, чи це буде 201 Created чи 400 badRequest зі списком того, що пішло не так. Забігаючи вперед - це наш новий сервіс валідації, він перевіряє вхідні дані на наявність обов'язкових полів (пам'ятаєте @NotBlank?) і вирішує - чи годиться нам така інформація чи ні. Перед тим як перейти до способу збереження, давайте цей сервіс валідації і реалізуємо. Для цього пропоную створити окремий пакет validation, до якого ми покладемо наш сервіс.
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();
    }
}
Клас вийшов завеликим, але без паніки, зараз розберемося :) Тут ми користуємося засобами вже готової бібліотеки валідації javax.validation Ця бібліотека нам підтягнулася з нових залежностей, які ми додали в build.graddle -validation' Наші старі знайомі Service і RequiredArgsConstructor Вже кажуть нам все, що нам потрібно знати про цей клас, так само тут є приватне поле validator. Він і творитиме магію. Ми створабо метод isValidEmployee, в який можна передати сутність Employee, цей метод викидає виняток ValidationException, яке ми напишемо трохи пізніше. Так, це буде кастомний виняток для наших потреб. За допомогою методу validator.validate(employee) ми отримаємо список об'єктів ConstraintViolation - всі ті невідповідності інструкціям валідації, які ми навісабо раніше. Далі логіка проста, якщо цей список не порожній (тобто порушення є) ми кидаємо виняток і будуємо список порушень - метод buildViolationsList Зверніть увагу, що це є Generic метод, тобто. може працювати зі списками порушень різних об'єктів - може стати в нагоді в майбутньому, якщо ми ще щось валідуватимемо. Що взагалі робить цей метод? Використовуючи stream API, ми проходимося за списком порушень. Кожне порушення в методі map ми перетворюємо на наш власний об'єкт порушення, і всі об'єкти, що виходять, збираємо в список. Його ж і повертаємо. Що за наш власний об'єкт порушення запитаєте ви? Ось такий простий record
public record Violation(String property, String message) {}
Рекорди це такі спеціальні нововведення в Java, якщо вам потрібен об'єкт з даними, без будь-якої логіки та іншого. Хоч я й сам поки що не зрозумів, навіщо це було зроблено, іноді досить зручна штука. Створювати треба в окремому файлі як звичайний клас. Повертаючись до кастомного ValidationException - виглядає він так:
@RequiredArgsConstructor
public class ValidationException extends Exception {

    @Getter
    private final List<Violation> violations;
}
У ньому зберігається список всіх порушень, на список навішена Lombok анотація - Getter, а через іншу Lombok анотацію ми "реалізували" потрібний конструктор :) Тут варто відзначити, що я не зовсім корректно реалізую поведінку методу isValid..., він повертає або true або виняток, а варто обмежитися звичайним False. Підхід з винятком зроблено тому, що цю помилку я хочу повертати клієнту, а значить мені потрібно з boolean методу повернути щось відмінне від true чи false. У випадку з методами суто внутрішньої валідації виняток не потрібно прокидати, тут знадобиться логування. Однак повернемося до нашого EmployeeService, треба все-таки почати зберігати об'єкти :) Подивимося як виглядає цей клас тепер:
@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());
    }
}
Зверніть увагу на нову final проперти private final ValidationService validationService; Сам метод save позначений анотацією @Transactional щоб у разі отримання RuntimeException зміни відкотабося. Насамперед ми валідуємо вхідні дані за допомогою щойно написаного сервісу. Якщо все пройшло гладко - перевіряємо, чи немає в базі вже існуючого співробітника (за унікальним номером). Якщо ні – зберігаємо нового, якщо є – оновлюємо поля у класі. Ах так, як власне перевіряємо? Так дуже просто, до репозиторій Employee ми додали новий метод:
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
    Employee findByUniqueNumber(String uniqueNumber);
}
Що чудово? Я не писав ніякої логіки чи SQL query, хоч і це тут доступно. Spring просто прочитавши назву методу визначає, що я хочу - знайтиПоУнікальнийНомер і передаю відповідний рядок в метод. Повертаючись до оновлення полів - тут я вирішив керуватися здоровим глуздом і оновити лише департамент, зарплату та завдання, бо зміна імені, хоч і допустима штука - все ж таки не дуже часта. А зміна дати прийому працювати так і взагалі спірне питання. Що тут було б добре зробити? Об'єднати списки завдань, але оскільки завдань у нас поки що немає і ми не знаємо як їх відрізняти - залишимо TODO. Спробуємо запустити наш франкенштейн. Якщо я нічого не забув описати він повинен працювати, але для початку - ось дерево класів яке у нас вийшло: REST API та Валідація даних - 1 Синім підсвічені класи які були модифіковані, зеленим - нові, такі індикації можна отримати, якщо працювати з git репозиторієм, але git не є темою для нашої статті, з цього зупинятись на ньому не будемо. Отже, на даний момент у нас є один ендпоінт, який підтримує два методи GET і POST. До речі, трохи цікавої інформації про ендпоінт. Чому, наприклад, ми не виділабо окремі ендпоінти для GET і POST наприклад getAllEmployees або createEmployees? Все дуже просто - мати єдину точку для всіх запитів, адже набагато зручніше. Маршрутизація відбувається ґрунтуючись на HTTP методі і це інтуїтивно зрозуміло, не потрібно запам'ятовувати всі варіації getAllEmployees, getEmployeeByName, get... update... create... delete... Давайте потестуємо що у нас вийшло. Я вже писав у минулій статті, що нам потрібно буде Postman, і саме час його встановити. В інтерфейсі програми створюємо новий запит POST REST API та Валідація даних - 2 і намагаємося відправити його. Якщо все пішло добре, ми отримаємо Status 201 у правій частині екрану. А от наприклад відправивши те саме але без унікального номера (на якому у нас валідація) я отримую вже іншу відповідь: REST API та Валідація даних - 3 Ну і перевіримо, як працює наша повна вибірка - створюємо GET метод на той же ендпоінт і шолом його. REST API та Валідація даних - 4 Щиро сподіваюся, що у вас все вийшло так само, як і у мене, ну і до зустрічі в наступній статті .
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ