JavaRush /Java блог /Random /REST API и расширение функционала.
Денис
37 уровень
Киев

REST API и расширение функционала.

Статья из группы Random
Ссылки на предыдущие части: 1. REST API и очередное тестовое задание 2. REST API и Валидация данных Что собственно из себя представляет "Задача" которую мы сегодня будем внедрять? Это некоторая сущность, в которой описываются требуемые действия, наверное у нее есть ожидаемый срок сдачи, ну это если по минимуму. Логично будет накинуть еще пару параметров, например имя задачи (для удобства), дата создания, возможно какой-то уникальный идентификатор, ну и от себя я еще добавлю департамент который над ней будет работать. Например если в будущем будет задача найти все задачи департамента "А". Сама Entity уже была создана в рамках предыдущей статьи, потому мы просто накинем в нее полей.

@Entity
@Data
@Accessors(chain = true)
@Table(indexes = @Index(name = "task_key_idx", columnList = "taskKey"))
public class Task {

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

    @Column(nullable = false)
    @NotBlank
    private String department;

    private String taskKey;

    private LocalDate createdDate;

    private LocalDate dueDate;

    @NotBlank
    private String taskName;

    @NotBlank
    private String description;

    @JsonIgnore
    @ManyToOne(fetch = FetchType.LAZY)
    Employee employee;

    @PostPersist
    public void generateTaskKey() {
        if (taskKey == null) {
            taskKey = StringUtils.joinWith("-", department, taskId);
        }
    }
}
Если пройтись по коду, можно отметить ряд новшеств. Первое - это аннотация @Table внутри которой я создаю индекс для taskKey поля. Оно будет у нас использоваться в дальнейшем и представляет из себя комбинацию из Департамента и ID самой таски. Что здесь сразу плохо? Это поле назначается в @PostPersist методе. В целом так делать не рекомендуют, хотябы потому, что изменив состояние сущности после персиста её нужно опять сохранять в базу, а это два обращения вместо одного. Само по себе оно кстати тоже работать не будет, для этого желательно метод save у репозитория вызывать в @Transactional сервисе. К сожалению сейчас я не придумал более удобного варианта создавать такой ключик, а обращаться к таске по ID напрямую не очень хотелось, возможно со временем я изменю эту логику. Кроме того в сущности видна ссылка на родительскую сущность Employee, она вполне может быть нулл, соотношение здесь многое к одному (на одного сотрудника может быть назначено несколько задач). Так же на этом поле стоит аннотация JsonIgnore. Сделано это для того, чтобы при сериализации мы не упали в бесконечную петлю, пытаясь сериализовать сотрудника с задачей ссылающейся на этого же сотрудника, который ссылается на эту же задачу... ну и так далее. Но если кто хочет попробовать просто уберите аннотацию и посмотрите на замечательную надпись Could not write JSON: Infinite recursion Так же нам потребуется отредактировать сущность сотрудника, в ней у нас был список задач помеченный как OneToMany, модифицируем её немного:

@OneToMany(mappedBy = "employee")
List<Task> tasks = new ArrayList<>();
Этот список маппится по сущности employee в таске. Так же я добавил новую аннотацию с индексом по аналогии с Task

@Table(indexes = @Index(name = "unique_number_idx", columnList = "uniqueNumber"))
Разобравшись с сущностями, самое время переходить к контроллеру, сервису, репозиторию и валидации. С контроллером всё относительно проще всего, делать будем под копирку с существующего Employee контроллер, только методов у него будет больше.

@RestController
@RequestMapping("${application.endpoint.task}")
@RequiredArgsConstructor
public class TaskController {

    private final TaskService taskService;

    @GetMapping
    public ResponseEntity<List<Task>> getUnassignedTasks() {
        return ResponseEntity.ok().body(taskService.findUnassignedTasks());
    }

    @PostMapping()
    public ResponseEntity<?> createOrUpdateTask(@RequestBody Task task) {
        try {
            String orUpdateTask = taskService.createOrUpdateTask(task);
            return ResponseEntity.status(HttpStatus.CREATED).body(orUpdateTask);
        } catch (ValidationException e) {
            return ResponseEntity.badRequest().body(e.getViolations());
        }
    }

    @PatchMapping()
    public ResponseEntity<?> assignEmployee(@RequestBody JsonNode jsonNode) {
        try {
            taskService.manageTask(jsonNode);
        } catch (ValidationException e) {
            return ResponseEntity.badRequest().body(e.getViolations());
        }

        return ResponseEntity.status(HttpStatus.CREATED).build();
    }

    @DeleteMapping
    public ResponseEntity<?> deleteByTaskKey(@RequestBody JsonNode jsonNode) {
        taskService.deleteByTaskKey(jsonNode);
        return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
    }
}
К известным уже GET и POST добавились еще PATCH и DELETE. Это всё тоже HTTP методы, которыми можно задать желаемое нами поведение. Здесь мне не нравится то, что почти в каждом из методов есть дубликация кода, по сути здесь дёргается TaskService, который может бросить исключение, это исключение ловится и отдаётся клиенту. В качестве TODO можно будет добавить рефакторинг этого чуда, тем более что статья на эту тему и с этим же сервисом точно будет нужна дальше. Однако, здесь можно пойти и другим путём, использовав OpenAPI спецификацию и валидацию. Выглядит в разы элегантнее, но это тема для отдельной статьи, для начала попробуем построить работающее приложение как есть. В некоторых методах я использую в качестве RequestBody объект JsonNode, это именно то чем кажется, обычный JSON Объект в котором может лежать всё что угодно. Для реализации на коленке удобно, но тоже есть смысл подумать над более изящным решением. как он будет использоваться я покажу на примере утилитарного класса который создал в пакете utils

@UtilityClass
public class FieldUtils {

    public static String getValueFromJsonNode(JsonNode jsonNode, String key) {
        return Optional.ofNullable(jsonNode.get(key))
                       .map(JsonNode::asText)
                       .orElse(null);
    }
}
Это обычный класс со статическим методом который из объекта JsonNode умеет возвращать значение по ключу в виде строки, ну или null если такого нет. Очень кстати пришёлся OptionalOfNullable, который в данном случае и занимается вызовом метода get на ключе, и если он есть - вызвает следующий метод asText. Если же нет - возвращает null. Однако вернёмся к задачам. Для того чтобы это всё завелось нам еще нужен сервис и правки в репозиторий. Класс TaskService вышел достаточно громоздким

@Service
@RequiredArgsConstructor
@Transactional
public class TaskService {

    private final TaskRepository taskRepository;
    private final EmployeeRepository employeeRepository;
    private final ValidationService<Task> validationService;

    public List<Task> findUnassignedTasks() {
        return taskRepository.findAllByEmployeeIsNull();
    }

    public String createOrUpdateTask(Task task) throws ValidationException {
        if (validationService.isValid(task)) {
            Task existingTask = taskRepository.findByTaskKey(task.getTaskKey());
            if (existingTask == null) {
                task.setCreatedDate(LocalDate.now());
                taskRepository.save(task);
            } else {
                taskRepository.save(updateFields(existingTask, task));
            }
        }
        return task.getTaskKey();
    }

    public void manageTask(JsonNode jsonNode) throws ValidationException {
        String taskKey = getValueFromJsonNode(jsonNode, "taskKey");
        String uniqueNumber = getValueFromJsonNode(jsonNode, "uniqueNumber");
        if (taskKey != null && uniqueNumber != null) {
            Employee byUniqueNumber = employeeRepository.findByUniqueNumber(uniqueNumber);
            Task byTaskKey = taskRepository.findByTaskKey(taskKey);
            if (byUniqueNumber != null && byTaskKey != null) {
                if (byTaskKey.getEmployee() != null) {
                    if (byTaskKey.getEmployee().getUniqueNumber().equals(uniqueNumber)) {
                        byTaskKey.setEmployee(null);
                    } else {
                        throw new ValidationException(List.of(new Violation("Error", "task is already assigned to user")));
                    }
                } else {
                    byTaskKey.setEmployee(byUniqueNumber);
                }
                taskRepository.save(byTaskKey);
            } else {
                throw new ValidationException(
                        List.of(new Violation("Not such task or employee found",
                                String.format("Task: %s, Employee: %s", byTaskKey, byUniqueNumber))));
            }
        } else {
            throw new ValidationException(List.of(new Violation("Error", "taskKey or employee uniqueNumber should not be blank")));
        }
    }

    public void deleteByTaskKey(JsonNode jsonNode) {
        String taskKey = getValueFromJsonNode(jsonNode, "taskKey");
        taskRepository.deleteTaskByTaskKey(taskKey);
    }



    private Task updateFields(Task existing, Task upcoming) {
        return existing.setTaskName(upcoming.getTaskName())
                       .setDescription(upcoming.getDescription())
                       .setDueDate(upcoming.getDueDate());
    }
}
Прямо на классе я повесил аннотацию Transactional, чтобы не писать ее над каждым из методов отдельно. В классе у нас есть три зависимости, это два репозитория для задач и служащих + сервис валидации. Если кто читал предыдущие части статьи, может заметить, что сервис валидации добавлен чуть не так, появилось имя валидируемого класса в уголках. Сервис валидации я несколько генерифицировал, чтобы им можно было пользоваться как для Employee так и для Task не создавая дополнительных методов, я покажу сам класс позже, важно отметить что метод отвечающий за валидацию теперь называется просто isValid. Так же в сервисе есть методы соответствующие методам контроллера, для создания задачи или ее обновления, для удаления задачи, для поиска задач которые еще ни на кого не назначены и, соответственно для назначения задач на кого-то. Весь код достаточно простой, потому почитайте что делает каждый метод в отдельности. Из минусов этого класса - ручные throw new ValidationException, сами по себе месседжи назначаются именно руками, что крайне плохо и тоже является поводом на подумать в дальнейшем. Почему так вышло? Из-за JsonNode, вот вам обратная сторона универсальности. Если бы у меня была какая-то DTO для этих целей, я бы просто навесил нужные аннотации и валидировал ее, но выбрав путь "наименьшего" сопротивления я нажил себе сложностей, код стал выглядеть плохо, в нём появились элементы хардкода которые будет очень неудобно актуализировать в случае чего. Я оставил такую реализацию в рамках этой статьи, чтобы показать к чему ведут "простые" решения. Последним штрихом будет модернизация репозитория, тут всё просто:

public interface TaskRepository extends JpaRepository<Task, Long> {

    Task findByTaskKey(String taskUuid);
    List<Task> findAllByEmployeeIsNull();
    void deleteTaskByTaskKey(String taskKey);
}
Любопытная деталь SpringJPA которая не перестаёт меня радовать, это возможность написать Query прямо в имени метода. Например findByTaskKey и программа прекрасно поймёт что я хочу, более того, IDE даже сама подсказывает что можно дописать. Что ж, остались мелочи, вышеупомянутый сервис валидации и мелкие правки для Employee, чтобы адаптировать изменения.

@Service
@RequiredArgsConstructor
public class ValidationService<T> {

    private final Validator validator;

    public boolean isValid(T t) throws ValidationException {
        Set<ConstraintViolation<T>> constraintViolations = validator.validate(t);

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

    private List<Violation> buildViolationsList(Set<ConstraintViolation<T>> constraintViolations) {
        return constraintViolations.stream()
                                   .map(violation -> new Violation(
                                                   violation.getPropertyPath().toString(),
                                                   violation.getMessage()
                                           )
                                   )
                                   .toList();
    }
}

По сравнению с прошлой версией можно заметить, что буква Т выползла за пределы метода buildViolationsList и теперь гордо находится как в приписке к имени класса, так и в методу isValid. Логика здесь простая, сам по себе метод делает одинаковую работу что для Task, что для Employee, так зачем же нам два метода если мы можем сделать его универсальным? Конечно же нужно внести соответствующие изменения в EmployeeService, а именно, поправить строку с зависимостью

    private final ValidationService<Employee> validationService;
И поправить сам вызов метода

//было
if (validationService.isValidEmployee(employee)) {
//стало
if (validationService.isValid(employee)) {
Так же, поразмыслив я убрал из метода updateFields строку с назначением задач, в случае обновления полей. Не нужно оно нам пока. Если всё сделано достаточно аккуратно и правильно, то приложение можно запускать и тестировать. Для этого нам понадобятся новые запросы в Postman или другом инструменте. Ниже я буду приводить примеры для curl, чтобы не было слишком много скриншотов. Проверим для начала создание новой задачи:

curl --location --request POST 'localhost:8086/api/v1/tasks' \
--header 'Content-Type: application/json' \
--data-raw '{
    "department": "HR",
    "dueDate": "2023-03-19",
    "taskName": "TestTask",
    "description": "This is a simple test task"
}'
Если всё хорошо, на такой запрос мы должны получить ответ 201 Created и соответствующий taskKey Уже во время написания этой статьи подумал, что в сущности Task не лишним был бы флаг completed :) но это оставим или на потом или если кому-то будет интересно попинать приложение более предметно и расширить под свои нужды. После создания задачи, она лежит никому не нужная и никем не подобранная. Список таких можно получить дёрнув другой метод нашего эндпоинта - GET

curl --location --request GET 'localhost:8086/api/v1/tasks'
На что приложенька должна будет выдать список задач. К слову о списках, еще одной хорошей идеей будет реализовать работу эндпоинтов через списки объектов. Например создать список задач, а не одну, назначить список задач ну и так далее. Делается оно не сложно и возможно будет рассмотрено в одной из следующих статей, энтузиасты же могут попробовать свои силы сами. Задачу можно назначить по ключу при помощи метода PATCH (тут я долго думал PUT или PATCH использовать, но PATCH показался семантически вернее, не утверждаю что это best practices).

curl --location --request PATCH 'localhost:8086/api/v1/tasks' \
--header 'Content-Type: application/json' \
--data-raw '{
    "taskKey": "HR-2",
    "uniqueNumber": "TEST54321"
}'
Проверить на сколько всё прошло успешно можно дёрнув GET метод эндпоинта employees кстати. Еще интересная особенность той логики, что я повесил на метод PATCH в том, что метод работает в обе стороны. Не назначенную таску он назначит, а вот если она уже принадлежит сотруднику то будет с него снята. Ну и DELETE, здесь всё просто и говорит само за себя:

curl --location --request DELETE 'localhost:8086/api/v1/tasks' \
--header 'Content-Type: application/json' \
--data-raw '{
    "taskKey" : "HR-3"
}'
В целом, хотя сделали мы сущую мелочь кода вышло достаточно много, он не весь хорош и требует доработок, но этим уже можно играться с большим интересом. Можно попробовать расширить логику работы с сотрудниками, например добавить возможность их удалять, это уже на усмотрение читателей. Я же планирую в следующих частях пройтись по таким темам как рефакторинг приложения, его тестирование, контейнеризация, и возможно перевод на работу с Open API
Комментарии (5)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
it Уровень 21
18 мая 2023
Сделал, работает! Спасибо)
GoldenAlf Уровень 28
1 мая 2023
Это круче, чем сериал ! С нетерпением ждём продолжение (а лучше как нетфликс все серии сразу)🤩 И спасибо за труд и просвещение 👍