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

REST API и Валидация данных

Статья из группы Random
Ссылка на первую часть: 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? Очевидно, что наш employeeService обзавелся методом save, который и должен отвечать за всю работу по сохранению. Очевидно также и то, что этот метод может выбрасывать исключение с говорящим названием. Т.е. проводится какая-то валидация. А ответ зависит непосредственно от результатов валидации, или это будет 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 implementation 'org.springframework.boot:spring-boot-starter-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 Искренне надеюсь, что у вас все получилось так же как и у меня, ну и до встречи в следующей статье.
Комментарии (14)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Roman Уровень 34
8 декабря 2023
Я бы исправил метод updateFields в классе EmployeeService, так как текущий метод updateFields заменяет весь список задач (tasks) существующего сотрудника (existingEmployee) на список задач из обновленного сотрудника (updatedEmployee). Это приводит к тому, что Hibernate считает старый список задач "отсоединенным" и удаляет его содержимое из-за каскадного удаления.

private Employee updateFields(Employee existingEmployee, Employee updatedEmployee) {
        existingEmployee.setDepartment(updatedEmployee.getDepartment())
                .setSalary(updatedEmployee.getSalary());

        // Обновление списка задач
        // Удаление ненужных задач и добавление новых
        existingEmployee.getTasks().clear(); // Очищаем текущий список задач
        existingEmployee.getTasks().addAll(updatedEmployee.getTasks()); // Добавляем все задачи из обновленного сотрудника

        return existingEmployee;
    }
it Уровень 21
16 мая 2023

Искренне надеюсь, что у вас все получилось
получилось)) с 100500го раза, но заработало :D
Anonymous #3137504 Уровень 20
7 февраля 2023
Есть ссылка на следующую часть?
ram0973 Уровень 41
28 августа 2022
Насчёт @Transactional тут бы поподробнее, нужен ли он здесь
ram0973 Уровень 41
28 августа 2022
Валидация конечно мощная, с запасом на будущее?
Павел Уровень 11
15 августа 2022
Про @ExceptionHandler потом напишешь?