diff --git a/.env b/.env new file mode 100644 index 00000000..1e97dd34 --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +BASEPROJECT_DATABASE_HOST=postgres +BASEPROJECT_DATABASE_PORT=5434 +BASEPROJECT_DATABASE_NAME=mynewdatabase +BASEPROJECT_DATABASE_USERNAME=postgres +BASEPROJECT_DATABASE_PASSWORD=root \ No newline at end of file diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000..d58dfb70 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..88b28fbc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM bellsoft/liberica-openjdk-alpine:22 AS builder +WORKDIR /application +COPY . . +RUN --mount=type=cache,target=/root/.m2 chmod +x mvnw && ./mvnw clean install -Dmaven.test.skip + +FROM bellsoft/liberica-openjre-alpine:22 AS layers +WORKDIR /application +COPY --from=builder /application/target/*.jar app.jar +RUN java -Djarmode=layertools -jar app.jar extract + +FROM bellsoft/liberica-openjre-alpine:22 +VOLUME /tmp +RUN adduser -S spring-user +USER spring-user +COPY --from=layers /application/dependencies/ ./ +COPY --from=layers /application/spring-boot-loader/ ./ +COPY --from=layers /application/snapshot-dependencies/ ./ +COPY --from=layers /application/application/ ./ + +ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"] diff --git a/README.md b/README.md index 60f40028..507b2a95 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Практическое задание 1 # Spring ToDo -Создайте простое RESTful API веб-сервиса с использованием Spring Boot. Должно быть разработано TODO приложение, которое позволяет вести список дел +Создайте простое RESTful API веб-сервиса с использованием Spring Boot. Должно быть разработано TODO приложение, +которое позволяет вести список дел **Описание:**
- [x] В качестве БД используйте PostgreSQL
@@ -18,3 +19,22 @@ 3. После выполнения работы открыть Pull Request в master **!!!СВОЕГО РЕПОЗИТОРИЯ!!!** 4. Назначить ментора ревьювером 5. Отправить ссылку на PR в тг чат ментору + +------------------------------------------------------------------------------------------------ + +Swagger находится по адресу - http://localhost:8081/swagger-ui/index.html# +PgAdmin находится по адресу - http://localhost:5050/, логин admin@admin.com, пароль root + +Для того, чтобы поднять проект в докере(редис, пгадмин, приложение, БД), необходимо установить переменные среды на ПК, в +*Переменных среды для пользователя <имя пользователя>* + +* Имя переменной: `BASEPROJECT_DATABASE_USERNAME` + Значение: `postgres` +* Имя переменной: `BASEPROJECT_DATABASE_PASSWORD` + Значение `root` +* Имя переменной: `BASEPROJECT_DATABASE_PORT` + Значение: `5434`(если порт свободен) +* Имя переменной: `BASEPROJECT_DATABASE_NAME` + Значение: `mynewdatabase` +* Имя переменной: `BASEPROJECT_DATABASE_HOST` + Значение: `localhost` \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 00000000..c9f4e88b --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,64 @@ +services: + postgres: + image: postgres:17.2 + restart: "no" + ports: + - "5434:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + POSTGRES_USER: ${BASEPROJECT_DATABASE_USERNAME} + POSTGRES_PASSWORD: ${BASEPROJECT_DATABASE_PASSWORD} + POSTGRES_DB: ${BASEPROJECT_DATABASE_NAME} + healthcheck: + test: pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB + interval: 10s + timeout: 5s + start_period: 10s + retries: 5 + + pgadmin: + image: dpage/pgadmin4:latest + restart: "no" + depends_on: + - postgres + ports: + - "5050:80" # внешний порт 5050 -> внутренний 80 (интерфейс pgAdmin) + environment: + PGADMIN_DEFAULT_EMAIL: "admin@admin.com" # любой логин + PGADMIN_DEFAULT_PASSWORD: "root" # любой пароль + + spring-to-do: + image: spring-to-do:latest + build: + context: . + args: + DOCKER_BUILDKIT: 1 + restart: "no" + ports: + - "8081:8080" + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/mynewdatabase + SPRING_DATASOURCE_USERNAME: postgres + SPRING_DATASOURCE_PASSWORD: root + SPRING_REDIS_HOST: redis + SPRING_REDIS_PORT: 6379 + depends_on: + - postgres + - redis + redis: + image: redis:7.4.2 + restart: "no" + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 10s + timeout: 5s + start_period: 10s + retries: 5 +volumes: + postgres_data: + redis_data: \ No newline at end of file diff --git a/pom.xml b/pom.xml index 03bc446d..f5a69767 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,24 @@ org.springframework.boot - spring-boot-starter + spring-boot-starter-validation + + + + org.projectlombok + lombok + true + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.6.0 + + + + org.springframework.boot + spring-boot-starter-web @@ -29,6 +46,35 @@ spring-boot-starter-test test + + + + org.springframework.boot + spring-boot-starter-jdbc + 3.4.2 + + + + + org.liquibase + liquibase-core + 4.31.0 + + + + org.postgresql + postgresql + runtime + + + + + org.springframework.boot + spring-boot-starter-data-redis + 3.4.2 + + + @@ -36,6 +82,14 @@ org.springframework.boot spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + diff --git a/src/main/java/com/emobile/springtodo/config/OpenApiConfig.java b/src/main/java/com/emobile/springtodo/config/OpenApiConfig.java new file mode 100644 index 00000000..ce0805da --- /dev/null +++ b/src/main/java/com/emobile/springtodo/config/OpenApiConfig.java @@ -0,0 +1,16 @@ +package com.emobile.springtodo.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Contact; +import io.swagger.v3.oas.annotations.info.Info; + +@OpenAPIDefinition(info = @Info( + title = "TO-DO List", + description = "TO-DO List System", + version = "1.0.0", + contact = @Contact( + name = "Sirik Vadim" + ) +)) +class OpenApiConfig { +} diff --git a/src/main/java/com/emobile/springtodo/controllers/HandlerExceptionController.java b/src/main/java/com/emobile/springtodo/controllers/HandlerExceptionController.java new file mode 100644 index 00000000..8b50eebf --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controllers/HandlerExceptionController.java @@ -0,0 +1,222 @@ +package com.emobile.springtodo.controllers; + + +import com.emobile.springtodo.exceptions.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static com.emobile.springtodo.exceptions.DescriptionUserExeption.GENERATION_ERROR; +import static com.emobile.springtodo.other.ConstantsClass.LOGGER_SYNTAX; + +/** + *
+ *     Контроллер, обрабатывающий все эксепшены, котоыре могут быть выброшены в процессе работы приложения
+ * 
+ */ +@ControllerAdvice +@Slf4j +public class HandlerExceptionController { + + /** + * Обработчик ExecutorNotFoundException + */ + @ExceptionHandler(EntityNotFoundException.class) + protected ResponseEntity handleExecutorNotFoundExeption(EntityNotFoundException e, HttpServletRequest request) { + log.error(LOGGER_SYNTAX, GENERATION_ERROR.getEnumDescription(), e.getClass(), e.getMessage(), e); + + ApiErrorResponse apiErrorResponse = buildApiErrorResponse( + HttpStatus.NOT_FOUND, + e.getMessage(), + request.getRequestURI(), + null + ); + + return new ResponseEntity<>(apiErrorResponse, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(DataCalendarNotBeNullException.class) + protected ResponseEntity handleExecutorNotFoundExeption(DataCalendarNotBeNullException e, HttpServletRequest request) { + log.error(LOGGER_SYNTAX, GENERATION_ERROR.getEnumDescription(), e.getClass(), e.getMessage(), e); + + ApiErrorResponse apiErrorResponse = buildApiErrorResponse( + HttpStatus.NOT_ACCEPTABLE, + e.getMessage(), + request.getRequestURI(), + null + ); + + return new ResponseEntity<>(apiErrorResponse, HttpStatus.NOT_ACCEPTABLE); + } + + @ExceptionHandler(StatusTaskNotBeNullException.class) + protected ResponseEntity handleExecutorNotFoundExeption(StatusTaskNotBeNullException e, HttpServletRequest request) { + log.error(LOGGER_SYNTAX, GENERATION_ERROR.getEnumDescription(), e.getClass(), e.getMessage(), e); + + ApiErrorResponse apiErrorResponse = buildApiErrorResponse( + HttpStatus.NOT_ACCEPTABLE, + e.getMessage(), + request.getRequestURI(), + null + ); + + return new ResponseEntity<>(apiErrorResponse, HttpStatus.NOT_ACCEPTABLE); + } + + /** + * Обработчик ConstraintViolationException + */ + @ExceptionHandler(ConstraintViolationException.class) + protected ResponseEntity handleConstraintViolationException(ConstraintViolationException e, + HttpServletRequest request) { + log.error(LOGGER_SYNTAX, GENERATION_ERROR.getEnumDescription(), e.getClass(), e.getMessage(), e); + + Set> violationsSet = e.getConstraintViolations(); + List violations = (violationsSet != null) + ? violationsSet.stream() + .map(violation -> new Violation( + violation.getPropertyPath().toString(), + violation.getMessage() + )) + .collect(Collectors.toList()) + : Collections.emptyList(); + + ApiErrorResponse apiErrorResponse = buildApiErrorResponse( + HttpStatus.BAD_REQUEST, + e.getMessage(), + request.getRequestURI(), + violations + ); + + return new ResponseEntity<>(apiErrorResponse, HttpStatus.BAD_REQUEST); + } + + /** + * Обработчик MethodArgumentNotValidException + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + protected ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e, HttpServletRequest request) { + log.error(LOGGER_SYNTAX, GENERATION_ERROR.getEnumDescription(), e.getClass(), e.getMessage(), e); + + List violations = e.getBindingResult().getFieldErrors().stream() + .map(error -> new Violation(error.getField(), error.getDefaultMessage())) + .collect(Collectors.toList()); + + ApiErrorResponse apiErrorResponse = buildApiErrorResponse( + HttpStatus.BAD_REQUEST, + e.getMessage(), + request.getRequestURI(), + violations + ); + + return new ResponseEntity<>(apiErrorResponse, HttpStatus.BAD_REQUEST); + } + + /** + * Обработчик HttpMessageNotReadableException + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + protected ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e, HttpServletRequest request) { + log.error(LOGGER_SYNTAX, GENERATION_ERROR.getEnumDescription(), e.getClass(), e.getMessage(), e); + + ApiErrorResponse apiErrorResponse = buildApiErrorResponse( + HttpStatus.BAD_REQUEST, + e.getMessage(), + request.getRequestURI(), + null + ); + + return new ResponseEntity<>(apiErrorResponse, HttpStatus.BAD_REQUEST); + } + + /** + * Обработчик IllegalStateException + */ + @ExceptionHandler(IllegalStateException.class) + protected ResponseEntity handleIllegalStateException(IllegalStateException e, HttpServletRequest request) { + log.error(LOGGER_SYNTAX, GENERATION_ERROR.getEnumDescription(), e.getClass(), e.getMessage(), e); + + ApiErrorResponse apiErrorResponse = buildApiErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR, + e.getMessage(), + request.getRequestURI(), + null + ); + + return new ResponseEntity<>(apiErrorResponse, HttpStatus.INTERNAL_SERVER_ERROR); + } + + /** + * Обработчик DataIntegrityViolationException + */ + @ExceptionHandler(DataIntegrityViolationException.class) + protected ResponseEntity handleDataIntegrityViolationException(DataIntegrityViolationException e, + HttpServletRequest request) { + log.error(LOGGER_SYNTAX, GENERATION_ERROR.getEnumDescription(), e.getClass(), e.getMessage(), e); + + ApiErrorResponse apiErrorResponse = buildApiErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR, + e.getMessage(), + request.getRequestURI(), + null + ); + + return new ResponseEntity<>(apiErrorResponse, HttpStatus.INTERNAL_SERVER_ERROR); + } + + /** + * Общий обработчик для всех остальных исключений + */ + @ExceptionHandler(Exception.class) + protected ResponseEntity handleAllExceptions(Exception e, HttpServletRequest request) { + log.error(LOGGER_SYNTAX, GENERATION_ERROR.getEnumDescription(), e.getClass(), e.getMessage(), e); + + ApiErrorResponse apiErrorResponse = buildApiErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR, + e.getMessage(), + request.getRequestURI(), + null + ); + + return new ResponseEntity<>(apiErrorResponse, HttpStatus.INTERNAL_SERVER_ERROR); + } + + @ExceptionHandler(RuntimeException.class) + protected ResponseEntity handleAllRunTimeException(RuntimeException e, HttpServletRequest request) { + log.error(LOGGER_SYNTAX, GENERATION_ERROR.getEnumDescription(), e.getClass(), e.getMessage(), e); + + ApiErrorResponse apiErrorResponse = buildApiErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR, + e.getMessage(), + request.getRequestURI(), + null + ); + + return new ResponseEntity<>(apiErrorResponse, HttpStatus.INTERNAL_SERVER_ERROR); + } + + private ApiErrorResponse buildApiErrorResponse(HttpStatus status, String message, String path, List violations) { + return ApiErrorResponse.builder() + .timestamp(LocalDateTime.now()) + .status(status.value()) + .error(status.getReasonPhrase()) + .message(message) + .path(path) + .violations(violations) + .build(); + } +} diff --git a/src/main/java/com/emobile/springtodo/controllers/TaskController.java b/src/main/java/com/emobile/springtodo/controllers/TaskController.java new file mode 100644 index 00000000..b712e8d4 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controllers/TaskController.java @@ -0,0 +1,142 @@ +package com.emobile.springtodo.controllers; + +import com.emobile.springtodo.dto.TaskDto; +import com.emobile.springtodo.exceptions.DataCalendarNotBeNullException; +import com.emobile.springtodo.exceptions.EntityNotFoundException; +import com.emobile.springtodo.exceptions.StatusTaskNotBeNullException; +import com.emobile.springtodo.services.TaskService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Optional; + +import static com.emobile.springtodo.other.ConstantsClass.IS_DELETE; + +/** + *
+ *     Контроллер создания, редактирования, удаления, получения информации о задачах
+ * 
+ */ +@Tag(name = "Задачи", description = "Позволяет создать, редактировать, удалить, получить информацию о задачах") +@RestController +@RequestMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) +@Slf4j +@Validated +public class TaskController { + + private final TaskService taskService; + + @Autowired + TaskController(TaskService taskService) { + this.taskService = taskService; + } + + /** + * Эндпоинд POST для создания задачи + * + * @param tasksDto DTO объект задачи для создания + * @return {@link ResponseEntity} с созданной задачей + */ + @Operation(summary = "Создание задачи", description = "Позволяет создать задачу") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Задача успешно создана", content = @Content(examples = @ExampleObject(value = "\"{\\n \\\"id\\\": 1,\\n \\\"title\\\": \\\"Тестовая задача\\\",\\n \\\"description\\\": \\\"Тестовое описание задачи\\\",\\n \\\"status\\\": true,\\n \\\"dateCalendar\\\": \\\"2020-01-01T00:01:10\\\"\\n}\""))), + @ApiResponse(responseCode = "400", description = "Не удалось создать задачу", content = @Content) + }) + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Пример тела запроса для создания задачи", content = @Content(mediaType = "application/json", examples = @ExampleObject(name = "Создание задачи", value = "\"{\\n \\\"id\\\": 1,\\n \\\"title\\\": \\\"Тестовая задача\\\",\\n \\\"description\\\": \\\"Тестовое описание задачи\\\",\\n \\\"status\\\": true,\\n \\\"dateCalendar\\\": \\\"2020-01-01T00:01:10\\\"\\n}\""))) + @PostMapping(value = "/task/create") + public ResponseEntity createTask(@Valid @RequestBody @NotNull(message = "Объект не может быть null") + TaskDto tasksDto) throws DataCalendarNotBeNullException, StatusTaskNotBeNullException { + log.info("Создание задачи, POST "); + TaskDto localTasksDto = taskService.createTasks(tasksDto); + if (localTasksDto != null) { + return ResponseEntity.ok(localTasksDto); + } + return ResponseEntity.badRequest().build(); + } + + /** + * Эндпоинд GET для получения информации о задачах по исполнителю + * + * @param taskTitle - емейл пользователя, по которому будут найдены задачи, комментарии + * @param offset - номер страницы + * @param limit - кол-во сущностей на странице + * @return {@link ResponseEntity>} + */ + @Operation(summary = "Получить задачи по заголовку") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Возвращены задачи по заголовку", content = @Content(examples = @ExampleObject(value = "\"{\\n \\\"id\\\": 1,\\n \\\"title\\\": \\\"Тестовая задача\\\",\\n \\\"description\\\": \\\"Тестовое описание задачи\\\",\\n \\\"status\\\": true,\\n \\\"dateCalendar\\\": \\\"2020-01-01T00:01:10\\\"\\n}\""))), + @ApiResponse(responseCode = "400", description = "Не удалось получить задачи по заголовку", content = @Content) + }) + @GetMapping(value = "/task/list/{title}", consumes = MediaType.ALL_VALUE) + public ResponseEntity> getTasksByTitle( + @PathVariable("title") @Parameter(description = "Заголовок задачи") @NotBlank(message = "Заголовок задачи не может быть пустым") + String taskTitle, + @RequestParam(value = "offset", defaultValue = "0") @Min(0) @Parameter(description = "Номер страницы", example = "0") @NotNull(message = "Страница не может быть пустой") + Integer offset, + @RequestParam(value = "limit", defaultValue = "10") @Min(10) @Parameter(description = "Количество сущностей на странице", + example = "10") @NotNull(message = "Кол-во сущностей не может быть пустым") Integer limit + ) { + log.info("Получение задачи по исполнителю, метод GET " + taskTitle); + Optional> optionalExecutorTasksDtoList = taskService + .getTasksByTitle(taskTitle, offset, limit); + return optionalExecutorTasksDtoList.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.badRequest().build()); + } + + /** + * Эндпоинд PUT для редактирования задач + * + * @param tasksDto объект {@link TaskDto} + * @return {@link ResponseEntity<>} + */ + @Operation(summary = "Отредактировать задачу") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Задача успешно отредактирована", content = @Content(examples = @ExampleObject(value = "\"{\\n \\\"id\\\": 1,\\n \\\"title\\\": \\\"Тестовая задача\\\",\\n \\\"description\\\": \\\"Тестовое описание задачи\\\",\\n \\\"status\\\": true,\\n \\\"dateCalendar\\\": \\\"2020-01-01T00:01:10\\\"\\n}\""))), + @ApiResponse(responseCode = "400", description = "Не удалось отредактировать задачу", content = @Content) + }) + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Пример тела запроса для редактирования задачи", content = @Content(mediaType = "application/json", examples = @ExampleObject(name = "Редактирование задачи", value = "\"{\\n \\\"id\\\": 1,\\n \\\"title\\\": \\\"Тестовая задача\\\",\\n \\\"description\\\": \\\"Тестовое описание задачи\\\",\\n \\\"status\\\": true,\\n \\\"dateCalendar\\\": \\\"2020-01-01T00:01:10\\\"\\n}\""))) + @PutMapping("/task/update-tasks") + public ResponseEntity editTasks(@Valid @RequestBody @Parameter(description = "Объект TasksDto с полями, требующими редактирования") + @NotNull(message = "TasksDto не может быть null") TaskDto tasksDto) throws EntityNotFoundException { + Optional newTasksDto = taskService + .changeTasks(tasksDto); + return newTasksDto.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.badRequest().build()); + } + + /** + * Эндпоинд DELETE для удаления задач + * + * @param idTasks - id задачи для удаления + * @return {@link ResponseEntity} + */ + @Operation(summary = "Удалить задачу") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Задача успешно отредактирована", content = @Content(examples = @ExampleObject(value = "{ \"message\": \"Сущность была удалена\" }"))), + @ApiResponse(responseCode = "400", description = "Не удалось произвести удаление задачи", content = @Content) + }) + @DeleteMapping(value = "/task/delete/{id}", consumes = MediaType.ALL_VALUE) + public ResponseEntity deleteTasks(@PathVariable("id") @Parameter(description = "ID задачи") @NotNull(message = "ID задачи не может быть null") + Long idTasks) { + log.info("Удаление задачи по id, метод DELETE" + idTasks); + boolean result = taskService.deleteTasks(idTasks); + if (result) { + return ResponseEntity.ok(IS_DELETE); + } + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/emobile/springtodo/dto/TaskDto.java b/src/main/java/com/emobile/springtodo/dto/TaskDto.java new file mode 100644 index 00000000..14468b9f --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/TaskDto.java @@ -0,0 +1,47 @@ +package com.emobile.springtodo.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.validation.annotation.Validated; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Validated +public class TaskDto implements Serializable { + + @Schema(description = "Уникальный идентификатор задачи", example = "1") + @Min(value = 1, message = "Идентификатор задачи не должен быть меньше единицы") + @Max(value = 2147483647, message = "Идентификатор задачи не должен быть больше 2147483647") + private Long id; + + @Schema(description = "Заголовок задачи", example = "Тестовая задача") + @NotBlank(message = "Заголовок не может быть пуст") + @Size(min = 1, message = "Длина заголовка задачи не должна быть меньше 1 символа") + @Size(max = 2000, message = "Длина заголовка задачи не должна быть больше 2 тысяч символов") + private String title; + + @Schema(description = "Описание задачи", example = "Тестовое описание задачи") + @Size(max = 30000, message = "Длина описания не должна превышать 30000 символов") + private String description; + + @Schema(description = "Статус задачи", example = "true") + private Boolean status; + + @Schema(description = "Дата задачи в календаре", example = "2020-01-01T00:01:10") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime dateCalendar; + +} diff --git a/src/main/java/com/emobile/springtodo/entities/Task.java b/src/main/java/com/emobile/springtodo/entities/Task.java new file mode 100644 index 00000000..65917748 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/entities/Task.java @@ -0,0 +1,26 @@ +package com.emobile.springtodo.entities; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class Task { + + private Long id; + + private String title; + + private String description; + + private Boolean status; + + private LocalDateTime dateCalendar; + +} diff --git a/src/main/java/com/emobile/springtodo/exceptions/ApiErrorResponse.java b/src/main/java/com/emobile/springtodo/exceptions/ApiErrorResponse.java new file mode 100644 index 00000000..50fbc4ea --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exceptions/ApiErrorResponse.java @@ -0,0 +1,24 @@ +package com.emobile.springtodo.exceptions; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ApiErrorResponse { + + private LocalDateTime timestamp; + private int status; + private String error; + private String message; + private String path; + private List violations; + +} diff --git a/src/main/java/com/emobile/springtodo/exceptions/DataCalendarNotBeNullException.java b/src/main/java/com/emobile/springtodo/exceptions/DataCalendarNotBeNullException.java new file mode 100644 index 00000000..7e4cd8de --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exceptions/DataCalendarNotBeNullException.java @@ -0,0 +1,10 @@ +package com.emobile.springtodo.exceptions; + +/** + * Эксепшен, который выбрасывается, если поле дата задачи у сущности Задача null + */ +public class DataCalendarNotBeNullException extends Exception { + public DataCalendarNotBeNullException(String message) { + super(message); + } +} diff --git a/src/main/java/com/emobile/springtodo/exceptions/DescriptionUserExeption.java b/src/main/java/com/emobile/springtodo/exceptions/DescriptionUserExeption.java new file mode 100644 index 00000000..696a9ffc --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exceptions/DescriptionUserExeption.java @@ -0,0 +1,27 @@ +package com.emobile.springtodo.exceptions; + +/** + *
+ *     Enum с описанием для кастомных ошибок, которые могут выбрасываться в некоторых частях приложения
+ * 
+ */ +public enum DescriptionUserExeption { + + GENERATION_ERROR("Возникла ошибка в системе: "), + + TASK_NOT_FOUND_BY_ID("Задача по переданному ID не найдена"), + + STATUS_NOT_BE_NULL("Статус задачи не может быть пуст"), + + DATE_CALENDAR_NOT_BE_NULL("Дата задачи в календаре не может быть пустой"); + + private String enumDescription; + + DescriptionUserExeption(String enumDescription) { + this.enumDescription = enumDescription; + } + + public String getEnumDescription() { + return enumDescription; + } +} diff --git a/src/main/java/com/emobile/springtodo/exceptions/EntityNotFoundException.java b/src/main/java/com/emobile/springtodo/exceptions/EntityNotFoundException.java new file mode 100644 index 00000000..735672db --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exceptions/EntityNotFoundException.java @@ -0,0 +1,10 @@ +package com.emobile.springtodo.exceptions; + +/** + * Эксепшен, который выбрасывается, если сущность не найдена + */ +public class EntityNotFoundException extends Exception { + public EntityNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/emobile/springtodo/exceptions/StatusTaskNotBeNullException.java b/src/main/java/com/emobile/springtodo/exceptions/StatusTaskNotBeNullException.java new file mode 100644 index 00000000..b3734b46 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exceptions/StatusTaskNotBeNullException.java @@ -0,0 +1,10 @@ +package com.emobile.springtodo.exceptions; + +/** + * Эксепшен, который выбрасывается, если поле Статус у сущности Задача null + */ +public class StatusTaskNotBeNullException extends Exception { + public StatusTaskNotBeNullException(String message) { + super(message); + } +} diff --git a/src/main/java/com/emobile/springtodo/exceptions/Violation.java b/src/main/java/com/emobile/springtodo/exceptions/Violation.java new file mode 100644 index 00000000..1d0eba84 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exceptions/Violation.java @@ -0,0 +1,3 @@ +package com.emobile.springtodo.exceptions; + +public record Violation(String fieldName, String message) {} diff --git a/src/main/java/com/emobile/springtodo/mappers/TaskMapper.java b/src/main/java/com/emobile/springtodo/mappers/TaskMapper.java new file mode 100644 index 00000000..7aa7c367 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/mappers/TaskMapper.java @@ -0,0 +1,42 @@ +package com.emobile.springtodo.mappers; + +import com.emobile.springtodo.dto.TaskDto; +import com.emobile.springtodo.entities.Task; + +import java.util.List; + +/** + *
+ *     Маппер для {@link }, {@link }
+ * 
+ */ +public interface TaskMapper { + + /** + * Метод, конвертирующий {@link TaskDto} в {@link Task} + * + * @param tasksDto DTO объекта задачи + * @return Сконвертированный объект задачи + */ + Task convertDtoToTasks(TaskDto tasksDto); + + /** + * Метод, конвертирующий {@link Task} в {@link TaskDto} + * + * @param tasks Сущность задачи из базы данных + * @return Сконвертированный DTO объекта задачи + */ + TaskDto convertTasksToDto(Task tasks); + + /** + * Метод, сравнивающий поля Tasks из БД с TasksDto + * + * @param tasksDto DTO объекта задачи + * @param tasks Сущность задачи из базы данных + * @return Обновленный объект задачи {@link } + */ + void compareTaskAndDto(TaskDto tasksDto, Task tasks); + + List transferListTasksToDto(List tasksList); + +} diff --git a/src/main/java/com/emobile/springtodo/mappers/TaskMapperImpl.java b/src/main/java/com/emobile/springtodo/mappers/TaskMapperImpl.java new file mode 100644 index 00000000..fd0d5777 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/mappers/TaskMapperImpl.java @@ -0,0 +1,88 @@ +package com.emobile.springtodo.mappers; + +import com.emobile.springtodo.dto.TaskDto; +import com.emobile.springtodo.entities.Task; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Component +@Slf4j +public class TaskMapperImpl implements TaskMapper { + + @Override + public Task convertDtoToTasks(TaskDto tasksDto) { + log.info("Метод convertDtoToTasks()"); + Task taskLocalObject = new Task(); + if (tasksDto != null) { + taskLocalObject.setId(tasksDto.getId()); + taskLocalObject.setTitle(tasksDto.getTitle()); + taskLocalObject.setDescription(tasksDto.getDescription()); + taskLocalObject.setStatus(tasksDto.getStatus()); + taskLocalObject.setDateCalendar(tasksDto.getDateCalendar()); + } + return taskLocalObject; + } + + @Override + public TaskDto convertTasksToDto(Task tasks) { + log.info("Метод convertTasksToDto()"); + TaskDto tasksDtoLocalObject = new TaskDto(); + if (tasks != null) { + tasksDtoLocalObject.setId(tasks.getId()); + tasksDtoLocalObject.setTitle(tasks.getTitle()); + tasksDtoLocalObject.setDescription(tasks.getDescription()); + tasksDtoLocalObject.setStatus(tasks.getStatus()); + tasksDtoLocalObject.setDateCalendar(tasks.getDateCalendar()); + } + return tasksDtoLocalObject; + } + + @Override + public List transferListTasksToDto(List tasksList) { + List taskDtoList = new ArrayList<>(); + if (tasksList != null) { + for (Task tasks : tasksList) { + TaskDto taskDto = convertTasksToDto(tasks); + taskDtoList.add(taskDto); + } + } + return taskDtoList; + } + + @Override + public void compareTaskAndDto(TaskDto tasksDto, Task tasks) { + if (tasksDto != null) { + compareTasksAndDtoTitle(tasksDto, tasks); + compareTasksAndDtoDescription(tasksDto, tasks); + compareTasksAndDtoStatus(tasksDto, tasks); + compareTasksAndDtoDateCalendar(tasksDto, tasks); + } + } + + private void compareTasksAndDtoTitle(TaskDto tasksDto, Task tasks) { + if (tasksDto.getTitle() != null) { + tasks.setTitle(tasksDto.getTitle()); + } + } + + private void compareTasksAndDtoDescription(TaskDto tasksDto, Task tasks) { + if (tasksDto.getDescription() != null) { + tasks.setDescription(tasksDto.getDescription()); + } + } + + private void compareTasksAndDtoStatus(TaskDto tasksDto, Task tasks) { + if (tasksDto.getStatus() != null) { + tasks.setStatus(tasksDto.getStatus()); + } + } + + private void compareTasksAndDtoDateCalendar(TaskDto tasksDto, Task tasks) { + if (tasksDto.getDateCalendar() != null) { + tasks.setDateCalendar(tasksDto.getDateCalendar()); + } + } +} diff --git a/src/main/java/com/emobile/springtodo/other/ConstantsClass.java b/src/main/java/com/emobile/springtodo/other/ConstantsClass.java new file mode 100644 index 00000000..c77d18e4 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/other/ConstantsClass.java @@ -0,0 +1,23 @@ +package com.emobile.springtodo.other; + +/** + *
+ *     Класс с константами
+ * 
+ */ +public class ConstantsClass { + + public static final char PERCENT_CHAR_PRIMITIVE = '%'; + public static final Integer ZERO = 0; + public static final Integer ONE = 1; + public static final String IS_DELETE = "Entity was deleted"; + public static final String ID_FIELD = "id"; + public static final String TITLE_FIELD = "title"; + public static final String DESCRIPTION_FIELD = "description"; + public static final String STATUS_FIELD = "status"; + public static final String DATA_CALENDAR_FIELD = "date_calendar"; + public static final String LINE_FEED = "\n"; + public static final String PERCENT = "%"; + public static final String LOGGER_SYNTAX = "{}{}\\n{}\\n{}"; + +} diff --git a/src/main/java/com/emobile/springtodo/repositories/JdbcRepository.java b/src/main/java/com/emobile/springtodo/repositories/JdbcRepository.java new file mode 100644 index 00000000..f56db361 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repositories/JdbcRepository.java @@ -0,0 +1,50 @@ +package com.emobile.springtodo.repositories; + +/** + * Интерфейс-репозиторий с общими методами для запросов к БД + * + * @param Параметр, в котором идёт инкремент id у сущности + * @param Класс, который будет сохраняться в БД + */ +public interface JdbcRepository { + + /** + * Метод, позволяющий проверить в БД, существует ли сущность по переданному ID + * + * @param id id сущности + * @return true, если сущность есть в БД + */ + boolean existsById(T id); + + /** + * Метод, позволяющий сохранить объект в БД + * + * @param entity объект, который нужно сохранить в БД + * @return сохраненный в БД объект + */ + R save(R entity); + + /** + * Метод, позволяющий обновить объект в БД + * + * @param newEntity объект, который нужно обновить в БД + * @return обновленный в БД объект + */ + R update(R newEntity); + + /** + * Метод, позволяющий найти объект в БД по переданному ID + * + * @param id сущности + * @return объект из БД, найденный по ID + */ + R findById(T id); + + /** + * Метод, позволяющий удалить сущность из БД + * + * @param id сущности + */ + void deleteById(T id); + +} diff --git a/src/main/java/com/emobile/springtodo/repositories/TaskRepository.java b/src/main/java/com/emobile/springtodo/repositories/TaskRepository.java new file mode 100644 index 00000000..9ad3c138 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repositories/TaskRepository.java @@ -0,0 +1,15 @@ +package com.emobile.springtodo.repositories; + +import com.emobile.springtodo.entities.Task; + +import java.util.List; +import java.util.Optional; + +/** + * Интерфейс-репозиторий с кастомными методами для task + */ +public interface TaskRepository extends JdbcRepository { + + Optional> findAllByTitle(String title, Integer offset, Integer limit); + +} diff --git a/src/main/java/com/emobile/springtodo/repositories/TaskRepositoryImpl.java b/src/main/java/com/emobile/springtodo/repositories/TaskRepositoryImpl.java new file mode 100644 index 00000000..a720e155 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repositories/TaskRepositoryImpl.java @@ -0,0 +1,139 @@ +package com.emobile.springtodo.repositories; + +import com.emobile.springtodo.entities.Task; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.PreparedStatementCreator; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Timestamp; +import java.util.List; +import java.util.Optional; + +import static com.emobile.springtodo.other.ConstantsClass.*; + +@Slf4j +@Repository +public class TaskRepositoryImpl implements TaskRepository { + + private final JdbcTemplate jdbcTemplate; + + @Autowired + public TaskRepositoryImpl(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public boolean existsById(Long id) { + String sql = "SELECT COUNT(*) FROM tasks_entity WHERE id = ?"; + Integer count = jdbcTemplate.queryForObject(sql, Integer.class, id); + if (count != null) { + return count > 0; + } + return false; + } + + @Override + public Task save(Task entity) { + String sql = "INSERT INTO tasks_entity (title, description, status, date_calendar) " + + "VALUES (?, ?, ?, ?)"; + commonUpdate(sql, entity); + return entity; + } + + @Override + public Task update(Task newEntity) { + String sql = "UPDATE tasks_entity SET title = ?, description = ?, status = ?, date_calendar = ? WHERE id = ? "; + commonUpdate(sql, newEntity); + return newEntity; + } + + @Override + public Task findById(Long id) { + String sql = "SELECT id, title, description, status, date_calendar " + + "FROM tasks_entity WHERE id = ?"; + return jdbcTemplate.queryForObject(sql, rowMapper(), id); + } + + @Override + public Optional> findAllByTitle(String title, Integer offset, Integer limit) { + String sql = "SELECT id, title, description, status, date_calendar " + + "FROM tasks_entity WHERE title LIKE ? ORDER BY id LIMIT ? OFFSET ?"; + return Optional.of(jdbcTemplate.query(sql, rowMapper(), title, limit, offset)); + } + + @Override + public void deleteById(Long id) { + String sql = "DELETE FROM tasks_entity WHERE id = ?"; + jdbcTemplate.update(sql, id); + log.info("DEL method"); + } + + private RowMapper rowMapper() { + return (rs, rowNum) -> { + Task task = new Task(); + task.setId(rs.getLong(ID_FIELD)); + task.setTitle(rs.getString(TITLE_FIELD)); + task.setDescription(rs.getString(DESCRIPTION_FIELD)); + task.setStatus(rs.getBoolean(STATUS_FIELD)); + task.setDateCalendar(rs.getTimestamp(DATA_CALENDAR_FIELD).toLocalDateTime()); // ✅ Timestamp → LocalDateTime + return task; + }; + } + + /** + * Метод, сохраняющий, либо обновляющий запись в таблице. В объекте поле id null, если сохранение, либо обновление не удалось + * (т.к update возвращает кол-во измененных строк) + * + * @param sql SQL - запрос на обновление сущности, либо на сохранение + * @param entity Объект для сохрнения, либо обновления в БД + */ + private void commonUpdate(String sql, Task entity) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(preparedStatement(sql, entity), keyHolder); + Number key = keyHolder.getKey(); + if (key != null) { + entity.setId(key.longValue()); + } + } + + /** + * Метод, собирающий PreparedStatement через интерфейс PreparedStatementCreator для отправки запроса с параметрами к БД + * + * @param sql SQL - запрос на обновление сущности, либо на сохранение + * @param entity Объект для сохрнения, либо обновления в БД + * @return интерфейс {@link PreparedStatementCreator} + */ + private PreparedStatementCreator preparedStatement(String sql, Task entity) { + + return connection -> { + PreparedStatement ps = connection.prepareStatement(sql, new String[]{ID_FIELD}); + int counter = 1; + if (entity.getTitle() != null) { + ps.setString(counter, entity.getTitle()); + counter++; + } + if (entity.getDescription() != null) { + ps.setString(counter, entity.getDescription()); + counter++; + } + if (entity.getStatus() != null) { + ps.setBoolean(counter, entity.getStatus()); + counter++; + } + if (entity.getDateCalendar() != null) { + ps.setTimestamp(counter, Timestamp.valueOf(entity.getDateCalendar())); + counter++; + } + if (entity.getId() != null) { + ps.setLong(counter, entity.getId()); + } + return ps; + }; + } +} diff --git a/src/main/java/com/emobile/springtodo/services/TaskService.java b/src/main/java/com/emobile/springtodo/services/TaskService.java new file mode 100644 index 00000000..a59e6787 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/services/TaskService.java @@ -0,0 +1,52 @@ +package com.emobile.springtodo.services; + +import com.emobile.springtodo.dto.TaskDto; +import com.emobile.springtodo.exceptions.DataCalendarNotBeNullException; +import com.emobile.springtodo.exceptions.EntityNotFoundException; +import com.emobile.springtodo.exceptions.StatusTaskNotBeNullException; + +import java.util.List; +import java.util.Optional; + +/** + *
+ *     Интерфейс для работы с сущностью Tasks
+ * 
+ */ +public interface TaskService { + + /** + * Метод для создания задачи + * + * @param tasksDto DTO объекта задачи, содержащий данные для создания + * @return Созданный объект-задача в виде {@link TaskDto} + */ + TaskDto createTasks(TaskDto tasksDto) throws StatusTaskNotBeNullException, DataCalendarNotBeNullException; + + /** + * Метод для редактирования задачи + * + * @param tasksDto DTO объекта задачи с обновленными данными + * @return {@link Optional} с изменённым объектом-задачей, если задача найдена и успешно обновлена + */ + Optional changeTasks(TaskDto tasksDto) throws EntityNotFoundException; + + /** + * Метод для получения задач по заголовку + * + * @param authorOrExecutor Название задачи + * @param offset Номер страницы для пагинации + * @param limit Лимит записей на странице + * @return {@link Optional} со списком задач в виде {@link List<>} + */ + Optional> getTasksByTitle(String authorOrExecutor, Integer offset, Integer limit); + + /** + * Метод для удаления задачи + * + * @param idTasks ID задачи, которую нужно удалить + * @return true - если задача найдена и удалена успешно + */ + boolean deleteTasks(Long idTasks); + +} diff --git a/src/main/java/com/emobile/springtodo/services/TaskServicesImpl.java b/src/main/java/com/emobile/springtodo/services/TaskServicesImpl.java new file mode 100644 index 00000000..288f31b6 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/services/TaskServicesImpl.java @@ -0,0 +1,103 @@ +package com.emobile.springtodo.services; + +import com.emobile.springtodo.dto.TaskDto; +import com.emobile.springtodo.entities.Task; +import com.emobile.springtodo.exceptions.DataCalendarNotBeNullException; +import com.emobile.springtodo.exceptions.EntityNotFoundException; +import com.emobile.springtodo.exceptions.StatusTaskNotBeNullException; +import com.emobile.springtodo.mappers.TaskMapper; +import com.emobile.springtodo.repositories.TaskRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +import static com.emobile.springtodo.exceptions.DescriptionUserExeption.*; +import static com.emobile.springtodo.other.ConstantsClass.*; + +@Service +@Slf4j +public class TaskServicesImpl implements TaskService { + + private final TaskMapper taskMapper; + private final TaskRepository taskRepository; + + @Autowired + public TaskServicesImpl(TaskMapper taskMapper, TaskRepository taskRepository) { + this.taskMapper = taskMapper; + this.taskRepository = taskRepository; + } + + @CachePut(cacheNames = "taskServiceCache", key = "#result.id") + @Transactional + @Override + public TaskDto createTasks(TaskDto tasksDto) throws StatusTaskNotBeNullException, DataCalendarNotBeNullException { + if (tasksDto.getTitle() == null) { + throw new StatusTaskNotBeNullException(STATUS_NOT_BE_NULL.getEnumDescription()); + } else if (tasksDto.getDateCalendar() == null) { + throw new DataCalendarNotBeNullException(DATE_CALENDAR_NOT_BE_NULL.getEnumDescription()); + } + Task task = taskMapper.convertDtoToTasks(tasksDto); + task.setId(null); + task = taskRepository.save(task); + log.info("create"); + return taskMapper.convertTasksToDto(task); + } + + @CachePut(cacheNames = "taskServiceCache", key = "#tasksDto.id") + @Transactional + @Override + public Optional changeTasks(TaskDto tasksDto) throws EntityNotFoundException { + Task taskFromDB = taskRepository.findById(tasksDto.getId()); + if (taskFromDB == null) { + throw new EntityNotFoundException(TASK_NOT_FOUND_BY_ID.getEnumDescription()); + } + taskMapper.compareTaskAndDto(tasksDto, taskFromDB); + taskRepository.update(taskFromDB); + if (taskFromDB.getId() == null) { + return Optional.empty(); + } + log.info("change"); + return Optional.of(taskMapper.convertTasksToDto(taskFromDB)); + } + + @Cacheable(cacheNames = "taskServiceCache", key = "#result.?[].id") + @Transactional + @Override + public Optional> getTasksByTitle(String taskTitle, Integer offset, Integer limit) { + StringBuffer localStringBuffer = new StringBuffer(taskTitle); + char[] chars = taskTitle.toCharArray(); + if (chars[ZERO] != PERCENT_CHAR_PRIMITIVE) { + localStringBuffer.insert(ZERO, PERCENT); + } + if (chars[chars.length - ONE] != PERCENT_CHAR_PRIMITIVE) { + localStringBuffer.append(PERCENT); + } + Optional> optionalTaskList = taskRepository.findAllByTitle(localStringBuffer.toString(), offset, limit); + if (optionalTaskList.isPresent() && !optionalTaskList.get().isEmpty()) { + return Optional.of(taskMapper.transferListTasksToDto(optionalTaskList.get())); + } + log.info("get"); + return Optional.empty(); + } + + @CacheEvict(cacheNames = "taskServiceCache", key = "#idTasks") + @Transactional + @Override + public boolean deleteTasks(Long idTasks) { + boolean taskExist = taskRepository.existsById(idTasks); + if (taskExist) { + taskRepository.deleteById(idTasks); + log.info("delete"); + return true; + } + log.info("delete"); + return false; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5c3e5461..4ba605bd 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,13 @@ spring.application.name=SpringToDo +springdoc.swagger-ui.path=swagger-ui.html +spring.datasource.url=jdbc:postgresql://${BASEPROJECT_DATABASE_HOST}:${BASEPROJECT_DATABASE_PORT}/${BASEPROJECT_DATABASE_NAME} +spring.datasource.username=${BASEPROJECT_DATABASE_USERNAME} +spring.datasource.password=${BASEPROJECT_DATABASE_PASSWORD} +spring.datasource.driverClassName=org.postgresql.Driver +spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect +spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.yaml +spring.liquibase.enabled=true +spring.cache.type=redis +spring.data.redis.host=localhost +spring.data.redis.port=6379 +spring.cache.cache-names=taskServiceCache \ No newline at end of file diff --git a/src/main/resources/db/changelog/V1.0.0-create-table-changelog.xml b/src/main/resources/db/changelog/V1.0.0-create-table-changelog.xml new file mode 100644 index 00000000..2d03ef9e --- /dev/null +++ b/src/main/resources/db/changelog/V1.0.0-create-table-changelog.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/db/changelog/V1.0.1-testdata-in-tables-changelog.xml b/src/main/resources/db/changelog/V1.0.1-testdata-in-tables-changelog.xml new file mode 100644 index 00000000..2c133a8a --- /dev/null +++ b/src/main/resources/db/changelog/V1.0.1-testdata-in-tables-changelog.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml new file mode 100644 index 00000000..edcf8fb3 --- /dev/null +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -0,0 +1,5 @@ +databaseChangeLog: + - include: + file: db/changelog/V1.0.0-create-table-changelog.xml + - include: + file: db/changelog/V1.0.1-testdata-in-tables-changelog.xml \ No newline at end of file diff --git a/src/test/java/com/emobile/springtodo/controllers/TaskControllerTest.java b/src/test/java/com/emobile/springtodo/controllers/TaskControllerTest.java new file mode 100644 index 00000000..fbdb555a --- /dev/null +++ b/src/test/java/com/emobile/springtodo/controllers/TaskControllerTest.java @@ -0,0 +1,129 @@ +package com.emobile.springtodo.controllers; + +import com.emobile.springtodo.dto.TaskDto; +import com.emobile.springtodo.services.TaskService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +class TaskControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private TaskService taskService; + + @Autowired + private ObjectMapper objectMapper; + + @DisplayName("Проверка POST /task/create, при валидном запросе") + @Test + void createTask_ValidRequest_ReturnsCreatedTask() throws Exception { + TaskDto taskDto = new TaskDto(); + taskDto.setTitle("Valid Task"); + taskDto.setStatus(true); + taskDto.setDateCalendar(LocalDateTime.now()); + + when(taskService.createTasks(any())).thenReturn(taskDto); + + mockMvc.perform(post("/task/create") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(taskDto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("Valid Task")); + } + + @DisplayName("Проверка POST /task/create, при невалидном запросе") + @Test + void createTask_InvalidRequest_ReturnsBadRequest() throws Exception { + mockMvc.perform(post("/task/create") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"description\": \"Invalid Task\"}")) + .andExpect(status().isBadRequest()); + } + + @DisplayName("Проверка GET /task/list/{title}, при валидном запросе") + @Test + void getTasksByTitle_ExistingTitle_ReturnsTasks() throws Exception { + when(taskService.getTasksByTitle("Test", 0, 10)) + .thenReturn(Optional.of(Collections.singletonList(new TaskDto()))); + + mockMvc.perform(get("/task/list/Test?offset=0&limit=10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + @DisplayName("Проверка GET /task/list/{title}, при невалидном запросе") + @Test + void getTasksByTitle_NonExistingTitle_ReturnsBadRequest() throws Exception { + when(taskService.getTasksByTitle("Unknown", 0, 10)) + .thenReturn(Optional.empty()); + + mockMvc.perform(get("/task/list/Unknown?offset=0&limit=10")) + .andExpect(status().isBadRequest()); + } + + @DisplayName("Проверка GET /task/list/{title}, при валидном запросе") + @Test + void updateTask_ValidRequest_ReturnsUpdatedTask() throws Exception { + TaskDto taskDto = new TaskDto(); + taskDto.setId(1L); + taskDto.setTitle("Updated Task"); + + when(taskService.changeTasks(any())).thenReturn(Optional.of(taskDto)); + + mockMvc.perform(put("/task/update-tasks") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(taskDto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("Updated Task")); + } + + @DisplayName("Проверка GET /task/list/{title}, при невалидном запросе") + @Test + void updateTask_NonExistingTask_ReturnsBadRequest() throws Exception { + when(taskService.changeTasks(any())).thenReturn(Optional.empty()); + + mockMvc.perform(put("/task/update-tasks") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new TaskDto()))) + .andExpect(status().isBadRequest()); + } + + @DisplayName("Проверка DELETE /task/delete/{id}, при валидном запросе") + @Test + void deleteTask_ExistingId_ReturnsSuccess() throws Exception { + when(taskService.deleteTasks(1L)).thenReturn(true); + + mockMvc.perform(delete("/task/delete/1")) + .andExpect(status().isOk()) + .andExpect(content().string("Entity was deleted")); + } + + @DisplayName("Проверка DELETE /task/delete/{id}, при невалидном запросе") + @Test + void deleteTask_NonExistingId_ReturnsNoContent() throws Exception { + when(taskService.deleteTasks(999L)).thenReturn(false); + + mockMvc.perform(delete("/task/delete/999")) + .andExpect(status().isNoContent()); + } +} \ No newline at end of file diff --git a/src/test/java/com/emobile/springtodo/repositories/TaskRepositoryTest.java b/src/test/java/com/emobile/springtodo/repositories/TaskRepositoryTest.java new file mode 100644 index 00000000..991b9346 --- /dev/null +++ b/src/test/java/com/emobile/springtodo/repositories/TaskRepositoryTest.java @@ -0,0 +1,78 @@ +package com.emobile.springtodo.repositories; + +import com.emobile.springtodo.entities.Task; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.PreparedStatementCreator; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Optional; + +@ExtendWith(MockitoExtension.class) +class TaskRepositoryTest { + + @Mock + private JdbcTemplate jdbcTemplate; + + @InjectMocks + private TaskRepositoryImpl taskRepository; + + @DisplayName("Проверка метода existsById") + @Test + void existsById_WhenExists_ReturnsTrue() { + Mockito.when(jdbcTemplate.queryForObject(Mockito.anyString(), Mockito.eq(Integer.class), Mockito.anyLong())) + .thenReturn(1); + + Assertions.assertTrue(taskRepository.existsById(1L)); + Mockito.verify(jdbcTemplate).queryForObject( + "SELECT COUNT(*) FROM tasks_entity WHERE id = ?", + Integer.class, 1L + ); + } + + @DisplayName("Проверка метода save") + @Test + void save_ValidTask_GeneratesId() { + Task task = new Task(); + task.setTitle("Test"); + task.setStatus(true); + task.setDateCalendar(LocalDateTime.now()); + + Mockito.when(jdbcTemplate.update(Mockito.any(PreparedStatementCreator.class), + Mockito.any(GeneratedKeyHolder.class))) + .thenAnswer(inv -> { + GeneratedKeyHolder keyHolder = inv.getArgument(1); + keyHolder.getKeyList().add(new HashMap<>() {{ + put("id", 1L); + }}); + return 1; + }); + + Task result = taskRepository.save(task); + Assertions.assertEquals(1L, result.getId()); + } + + @DisplayName("Проверка метода findAll") + @Test + void findAllByTitle_ValidTitle_ReturnsTasks() { + Task task = new Task(); + Mockito.when(jdbcTemplate.query(Mockito.anyString(), Mockito.any(RowMapper.class), Mockito.any(), + Mockito.anyInt(), Mockito.anyInt())) + .thenReturn(List.of(task)); + + Optional> result = taskRepository.findAllByTitle("test", 0, 10); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals(1, result.get().size()); + } +} \ No newline at end of file diff --git a/src/test/java/com/emobile/springtodo/services/TaskServicesTest.java b/src/test/java/com/emobile/springtodo/services/TaskServicesTest.java new file mode 100644 index 00000000..c1ad5e6b --- /dev/null +++ b/src/test/java/com/emobile/springtodo/services/TaskServicesTest.java @@ -0,0 +1,112 @@ +package com.emobile.springtodo.services; + +import com.emobile.springtodo.dto.TaskDto; +import com.emobile.springtodo.entities.Task; +import com.emobile.springtodo.exceptions.DataCalendarNotBeNullException; +import com.emobile.springtodo.exceptions.EntityNotFoundException; +import com.emobile.springtodo.exceptions.StatusTaskNotBeNullException; +import com.emobile.springtodo.mappers.TaskMapper; +import com.emobile.springtodo.repositories.TaskRepository; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Optional; + +@ExtendWith(MockitoExtension.class) +class TaskServicesTest { + + @Mock + private TaskMapper taskMapper; + + @Mock + private TaskRepository taskRepository; + + @InjectMocks + private TaskServicesImpl taskServices; + + private TaskDto taskDto; + private Task task; + + @BeforeEach + void setUp() { + taskDto = new TaskDto(1L, "Test Task", "Description", true, LocalDateTime.now()); + task = new Task(1L, "Test Task", "Description", true, LocalDateTime.now()); + } + + @DisplayName("Проверка возвращаемого значения метода createTasks") + @Test + void createTasks_ShouldReturnCreatedTask() throws StatusTaskNotBeNullException, DataCalendarNotBeNullException { + Mockito.when(taskMapper.convertDtoToTasks(Mockito.any())).thenReturn(task); + Mockito.when(taskRepository.save(Mockito.any())).thenReturn(task); + Mockito.when(taskMapper.convertTasksToDto(Mockito.any())).thenReturn(taskDto); + + TaskDto createdTask = taskServices.createTasks(taskDto); + + Assertions.assertNotNull(createdTask); + Assertions.assertEquals("Test Task", createdTask.getTitle()); + Mockito.verify(taskRepository, Mockito.times(1)).save(Mockito.any(Task.class)); + } + + @DisplayName("Проверка выбрасываемого эксепешена StatusTaskNotBeNullException метода createTasks") + @Test + void createTasks_ShouldThrowStatusTaskNotBeNullException() { + taskDto.setTitle(null); + Assertions.assertThrows(StatusTaskNotBeNullException.class, () -> taskServices.createTasks(taskDto)); + } + + @DisplayName("Проверка выбрасываемого эксепешена DataCalendarNotBeNullException метода createTasks") + @Test + void createTasks_ShouldThrowDataCalendarNotBeNullException() { + taskDto.setDateCalendar(null); + Assertions.assertThrows(DataCalendarNotBeNullException.class, () -> taskServices.createTasks(taskDto)); + } + + @DisplayName("Проверка возвращаемого значения метода changeTasks") + @Test + void changeTasks_ShouldReturnUpdatedTask() throws EntityNotFoundException { + Mockito.when(taskRepository.findById(taskDto.getId())).thenReturn(task); + Mockito.when(taskMapper.convertTasksToDto(Mockito.any())).thenReturn(taskDto); + + Optional updatedTask = taskServices.changeTasks(taskDto); + + Assertions.assertTrue(updatedTask.isPresent()); + Mockito.verify(taskRepository, Mockito.times(1)).update(Mockito.any(Task.class)); + } + + @DisplayName("роверка выбрасываемого эксепешена EntityNotFoundException метода changeTasks") + @Test + void changeTasks_ShouldThrowEntityNotFoundException() { + Mockito.when(taskRepository.findById(Mockito.anyLong())).thenReturn(null); + Assertions.assertThrows(EntityNotFoundException.class, () -> taskServices.changeTasks(taskDto)); + } + + @DisplayName("Проверка возвращаемого значения метода deleteTasks, сценарий, когда true") + @Test + void deleteTasks_ShouldReturnTrue() { + Mockito.when(taskRepository.existsById(Mockito.anyLong())).thenReturn(true); + Mockito.doNothing().when(taskRepository).deleteById(Mockito.anyLong()); + + boolean result = taskServices.deleteTasks(1L); + + Assertions.assertTrue(result); + Mockito.verify(taskRepository, Mockito.times(1)).deleteById(Mockito.anyLong()); + } + + @DisplayName("Проверка возвращаемого значения метода deleteTasks, сценарий, когда false") + @Test + void deleteTasks_ShouldReturnFalse() { + Mockito.when(taskRepository.existsById(Mockito.anyLong())).thenReturn(false); + boolean result = taskServices.deleteTasks(1L); + + Assertions.assertFalse(result); + Mockito.verify(taskRepository, Mockito.never()).deleteById(Mockito.anyLong()); + } +}