diff --git a/pom.xml b/pom.xml index 03bc446d..9476f6e5 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ org.springframework.boot spring-boot-starter-parent 3.3.5 - + com.emobile SpringToDo @@ -19,18 +19,92 @@ + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-jdbc + + + + + org.postgresql + postgresql + 42.7.4 + + + org.springframework.boot - spring-boot-starter + spring-boot-starter-cache + + org.springframework.boot + spring-boot-starter-data-redis + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.5.0 + + + + org.projectlombok + lombok + provided + + + org.springframework.boot spring-boot-starter-test test + + + org.testcontainers + testcontainers + test + + + + + org.testcontainers + postgresql + test + + + + + org.flywaydb + flyway-core + 11.3.2 + + + + org.flywaydb + flyway-database-postgresql + 11.3.2 + + + + diff --git a/src/main/java/com/emobile/springtodo/SpringToDoApplication.java b/src/main/java/com/emobile/springtodo/SpringToDoApplication.java index 41980514..f9e0190e 100644 --- a/src/main/java/com/emobile/springtodo/SpringToDoApplication.java +++ b/src/main/java/com/emobile/springtodo/SpringToDoApplication.java @@ -3,6 +3,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +/** + * Главный класс приложения Spring Boot для ToDo. + * Запускает приложение. + * + * @author PavelOkhrimchuk + */ @SpringBootApplication public class SpringToDoApplication { diff --git a/src/main/java/com/emobile/springtodo/configuration/OpenApiConfig.java b/src/main/java/com/emobile/springtodo/configuration/OpenApiConfig.java new file mode 100644 index 00000000..097fb9a0 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/configuration/OpenApiConfig.java @@ -0,0 +1,30 @@ +package com.emobile.springtodo.configuration; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Конфигурация OpenAPI для документации API. + * Настроена информация о версии и описании API. + * + * @author PavelOkhrimchuk + */ +@Configuration +public class OpenApiConfig { + + /** + * Создает объект OpenAPI с информацией о приложении. + * + * @return OpenAPI объект с настроенной информацией. + */ + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("Spring ToDo API") + .version("1.0") + .description("API для управления задачами ToDo")); + } +} diff --git a/src/main/java/com/emobile/springtodo/configuration/RedisConfig.java b/src/main/java/com/emobile/springtodo/configuration/RedisConfig.java new file mode 100644 index 00000000..6138e2b3 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/configuration/RedisConfig.java @@ -0,0 +1,34 @@ +package com.emobile.springtodo.configuration; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; + +/** + * Конфигурация Redis кеширования. + * Включает создание и настройку CacheManager для работы с Redis в приложении. + * + * @author PavelOkhrimchuk + */ +@Configuration +@EnableCaching +public class RedisConfig { + + /** + * Создает и настраивает CacheManager для работы с Redis. + * + * @param connectionFactory Фабрика подключения к Redis. + * @return CacheManager, использующий Redis для кеширования. + */ + @Bean + public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { + RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig(); + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(cacheConfig) + .build(); + } +} diff --git a/src/main/java/com/emobile/springtodo/controller/GlobalExceptionHandler.java b/src/main/java/com/emobile/springtodo/controller/GlobalExceptionHandler.java new file mode 100644 index 00000000..21e3d6b6 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controller/GlobalExceptionHandler.java @@ -0,0 +1,107 @@ +package com.emobile.springtodo.controller; + +import com.emobile.springtodo.dto.ErrorResponse; +import com.emobile.springtodo.exception.InvalidToDoException; +import com.emobile.springtodo.exception.ToDoNotFoundException; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +/** + * Глобальный обработчик исключений для всего приложения. + * Обрабатывает различные исключения, такие как ToDoNotFoundException и InvalidToDoException, + * а также ошибки валидации. + * + * @author PavelOkhrimchuk + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * Строит ответ на основе переданного исключения, статуса HTTP и ошибки валидации. + * + * @param ex Исключение, которое было выброшено. + * @param status Статус HTTP-ответа. + * @param request HTTP-запрос, содержащий путь. + * @param validationErrors Ошибки валидации (если имеются). + * @return Ответ с ошибкой. + */ + private ResponseEntity buildResponse(Exception ex, HttpStatus status, HttpServletRequest request, Map validationErrors) { + log.error("Exception: {} - {}", ex.getClass().getSimpleName(), ex.getMessage(), ex); + + ErrorResponse response = ErrorResponse.builder() + .timestamp(LocalDateTime.now()) + .status(status.value()) + .error(status.getReasonPhrase()) + .message(ex.getMessage()) + .path(request.getRequestURI()) + .build(); + + if (validationErrors != null && !validationErrors.isEmpty()) { + response.setDetails(validationErrors); + } + + return ResponseEntity.status(status).body(response); + } + + /** + * Обработчик исключения ToDoNotFoundException. + * @param ex Исключение, выброшенное при отсутствии задачи. + * @param request HTTP-запрос. + * @return Ответ с ошибкой 404. + */ + @ExceptionHandler(ToDoNotFoundException.class) + public ResponseEntity handleNotFound(ToDoNotFoundException ex, HttpServletRequest request) { + return buildResponse(ex, HttpStatus.NOT_FOUND, request, null); + } + + /** + * Обработчик исключения InvalidToDoException. + * @param ex Исключение, выброшенное при недействительной задаче. + * @param request HTTP-запрос. + * @return Ответ с ошибкой 400. + */ + @ExceptionHandler(InvalidToDoException.class) + public ResponseEntity handleInvalidToDo(InvalidToDoException ex, HttpServletRequest request) { + return buildResponse(ex, HttpStatus.BAD_REQUEST, request, null); + } + + /** + * Обработчик исключений валидации. + * @param ex Исключение с ошибками валидации. + * @param request HTTP-запрос. + * @return Ответ с ошибками валидации. + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationExceptions(MethodArgumentNotValidException ex, HttpServletRequest request) { + Map validationErrors = new HashMap<>(); + for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) { + validationErrors.put(fieldError.getField(), fieldError.getDefaultMessage()); + } + + log.error("Validation failed: {}", validationErrors); + + return buildResponse(ex, HttpStatus.BAD_REQUEST, request, validationErrors); + } + + /** + * Обработчик общих исключений. + * @param ex Общее исключение. + * @param request HTTP-запрос. + * @return Ответ с ошибкой 500. + */ + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneral(Exception ex, HttpServletRequest request) { + return buildResponse(ex, HttpStatus.INTERNAL_SERVER_ERROR, request, null); + } +} diff --git a/src/main/java/com/emobile/springtodo/controller/ToDoController.java b/src/main/java/com/emobile/springtodo/controller/ToDoController.java new file mode 100644 index 00000000..3aad2d61 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controller/ToDoController.java @@ -0,0 +1,110 @@ +package com.emobile.springtodo.controller; + +import com.emobile.springtodo.dto.ToDoItemDto; +import com.emobile.springtodo.service.ToDoService; +import io.swagger.v3.oas.annotations.Operation; +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 lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * Контроллер для работы с задачами ToDo. + * Предоставляет API для получения, создания, обновления и удаления задач. + * + * @author PavelOkhrimchuk + */ +@RestController +@RequestMapping("/api/todos") +@RequiredArgsConstructor +@Tag(name = "ToDo API", description = "API для управления задачами ToDo") +public class ToDoController { + + private final ToDoService toDoService; + + /** + * Получить все задачи с возможностью пагинации. + * + * @param limit Количество задач для выборки. + * @param offset Смещение для выборки. + * @return Список задач. + */ + @GetMapping + @Operation(summary = "Получить все задачи", description = "Получает список всех задач с возможностью пагинации") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Успешное получение списка задач"), + @ApiResponse(responseCode = "400", description = "Ошибка пагинации") + }) + public List getAll(@RequestParam(defaultValue = "10") int limit, + @RequestParam(defaultValue = "0") int offset) { + return toDoService.getAll(limit, offset); + } + + /** + * Получить задачу по уникальному идентификатору. + * @param id Идентификатор задачи. + * @return Задача с данным ID. + */ + @GetMapping("/{id}") + @Operation(summary = "Получить задачу по ID", description = "Получает задачу по ее уникальному идентификатору") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Задача успешно получена"), + @ApiResponse(responseCode = "404", description = "Задача не найдена") + }) + public ToDoItemDto getById(@PathVariable Long id) { + return toDoService.getById(id); + } + + /** + * Создать новую задачу. + * @param dto Данные задачи для создания. + * @return Созданная задача. + */ + @PostMapping + @Operation(summary = "Создать новую задачу", description = "Создает новую задачу") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Задача успешно создана"), + @ApiResponse(responseCode = "400", description = "Ошибка валидации данных") + }) + public ResponseEntity create(@Valid @RequestBody ToDoItemDto dto) { + return ResponseEntity.ok(toDoService.create(dto)); + } + + /** + * Обновить задачу по ID. + * @param id Идентификатор задачи для обновления. + * @param dto Данные задачи для обновления. + * @return Обновленная задача. + */ + @PutMapping("/{id}") + @Operation(summary = "Обновить задачу", description = "Обновляет существующую задачу по ее ID") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Задача успешно обновлена"), + @ApiResponse(responseCode = "404", description = "Задача не найдена") + }) + public ResponseEntity update(@PathVariable Long id, @Valid @RequestBody ToDoItemDto dto) { + return ResponseEntity.ok(toDoService.update(id, dto)); + } + + /** + * Удалить задачу по ID. + * @param id Идентификатор задачи для удаления. + * @return Ответ без содержимого (204). + */ + @DeleteMapping("/{id}") + @Operation(summary = "Удалить задачу", description = "Удаляет задачу по ее ID") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "Задача успешно удалена"), + @ApiResponse(responseCode = "404", description = "Задача не найдена") + }) + public ResponseEntity delete(@PathVariable Long id) { + toDoService.delete(id); + return ResponseEntity.noContent().build(); + } + +} diff --git a/src/main/java/com/emobile/springtodo/dto/ErrorResponse.java b/src/main/java/com/emobile/springtodo/dto/ErrorResponse.java new file mode 100644 index 00000000..71d4e075 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/ErrorResponse.java @@ -0,0 +1,58 @@ +package com.emobile.springtodo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * Ответ с ошибкой, возвращаемый при возникновении исключения в приложении. + * Содержит информацию о времени возникновения ошибки, статусе HTTP, типе ошибки, сообщении, пути запроса и деталях ошибки. + * + * @author PavelOkhrimchuk + */ +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +@Schema(description = "Ответ с ошибкой") +public class ErrorResponse { + + /** + * Время возникновения ошибки. + */ + @Schema(description = "Время возникновения ошибки", example = "2023-10-24T15:30:00") + private LocalDateTime timestamp; + + /** + * HTTP статус код ошибки. + */ + @Schema(description = "HTTP статус код ошибки", example = "400") + private int status; + + /** + * Тип ошибки (например, Bad Request). + */ + @Schema(description = "Тип ошибки", example = "Bad Request") + private String error; + + /** + * Сообщение об ошибке. + */ + @Schema(description = "Сообщение об ошибке", example = "Задача не найдена") + private String message; + + /** + * Путь запроса, который вызвал ошибку. + */ + @Schema(description = "Путь запроса, который вызвал ошибку", example = "/api/todos/1") + private String path; + + /** + * Подробности ошибок валидации, если они есть. + */ + @Schema(description = "Подробности ошибок валидации, если есть", example = "{\"field\":\"Title cannot be blank\"}") + private Map details; +} diff --git a/src/main/java/com/emobile/springtodo/dto/ToDoItemDto.java b/src/main/java/com/emobile/springtodo/dto/ToDoItemDto.java new file mode 100644 index 00000000..f0e89026 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/ToDoItemDto.java @@ -0,0 +1,52 @@ +package com.emobile.springtodo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.*; + +import java.io.Serializable; + +/** + * DTO для задачи (ToDoItem). + * Содержит поля задачи и использует аннотации для валидации данных. + * + * @author PavelOkhrimchuk + */ +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ToDoItemDto implements Serializable { + + /** + * Уникальный идентификатор задачи. + */ + @Schema(description = "Уникальный идентификатор задачи", example = "1") + private Long id; + + /** + * Название задачи. + * Не может быть пустым и должно быть не более 100 символов. + */ + @NotBlank(message = "Title cannot be blank") + @Size(max = 100, message = "Title must be at most 100 characters long") + @Schema(description = "Название задачи", example = "Сделать уборку", maxLength = 100) + private String title; + + /** + * Описание задачи. + * Должно быть не более 1000 символов. + */ + @Size(max = 1000, message = "Description must be at most 1000 characters long") + @Schema(description = "Описание задачи", example = "Убрать кухню и зал", maxLength = 1000) + private String description; + + /** + * Статус выполнения задачи. + * true — задача выполнена, false — задача не выполнена. + */ + @Schema(description = "Статус выполнения задачи", example = "false") + private boolean completed; +} diff --git a/src/main/java/com/emobile/springtodo/entity/ToDoItem.java b/src/main/java/com/emobile/springtodo/entity/ToDoItem.java new file mode 100644 index 00000000..80fc7ee1 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/entity/ToDoItem.java @@ -0,0 +1,47 @@ +package com.emobile.springtodo.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * Класс, представляющий задачу (ToDoItem) в системе. + * Содержит информацию о задаче, такую как идентификатор, название, описание, статус выполнения и дата создания. + * + * @author PavelOkhrimchuk + */ +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class ToDoItem { + + /** + * Уникальный идентификатор задачи. + */ + private Long id; + + /** + * Название задачи. + */ + private String title; + + /** + * Описание задачи. + */ + private String description; + + /** + * Статус выполнения задачи. + */ + private boolean completed; + + /** + * Дата и время создания задачи. + */ + private LocalDateTime createdAt; + +} diff --git a/src/main/java/com/emobile/springtodo/exception/InvalidToDoException.java b/src/main/java/com/emobile/springtodo/exception/InvalidToDoException.java new file mode 100644 index 00000000..3a2b77ac --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exception/InvalidToDoException.java @@ -0,0 +1,18 @@ +package com.emobile.springtodo.exception; + +/** + * Исключение, которое выбрасывается, если задача ToDo является недействительной. + * + * @author PavelOkhrimchuk + */ +public class InvalidToDoException extends RuntimeException { + + /** + * Конструктор исключения. + * + * @param message Сообщение об ошибке. + */ + public InvalidToDoException(String message) { + super(message); + } +} diff --git a/src/main/java/com/emobile/springtodo/exception/ToDoNotFoundException.java b/src/main/java/com/emobile/springtodo/exception/ToDoNotFoundException.java new file mode 100644 index 00000000..3efc1180 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exception/ToDoNotFoundException.java @@ -0,0 +1,18 @@ +package com.emobile.springtodo.exception; + +/** + * Исключение, которое выбрасывается, если задача ToDo не найдена по ID. + * + * @author PavelOkhrimchuk + */ +public class ToDoNotFoundException extends RuntimeException { + + /** + * Конструктор исключения с ID задачи. + * + * @param id Идентификатор задачи. + */ + public ToDoNotFoundException(Long id) { + super("ToDo item with id " + id + " not found"); + } +} diff --git a/src/main/java/com/emobile/springtodo/mapper/ToDoItemMapper.java b/src/main/java/com/emobile/springtodo/mapper/ToDoItemMapper.java new file mode 100644 index 00000000..c11d6a07 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/mapper/ToDoItemMapper.java @@ -0,0 +1,27 @@ +package com.emobile.springtodo.mapper; + +import com.emobile.springtodo.dto.ToDoItemDto; +import com.emobile.springtodo.entity.ToDoItem; + +/** + * Интерфейс для маппинга между сущностью ToDoItem и DTO ToDoItemDto. + * + * @author PavelOkhrimchuk + */ +public interface ToDoItemMapper { + + /** + * Преобразует DTO в сущность. + * + * @param dto Объект ToDoItemDto, который нужно преобразовать в сущность. + * @return Сущность ToDoItem. + */ + ToDoItem toEntity(ToDoItemDto dto); + + /** + * Преобразует сущность в DTO. + * @param entity Объект ToDoItem, который нужно преобразовать в DTO. + * @return DTO ToDoItemDto. + */ + ToDoItemDto toDto(ToDoItem entity); +} diff --git a/src/main/java/com/emobile/springtodo/mapper/ToDoItemMapperImpl.java b/src/main/java/com/emobile/springtodo/mapper/ToDoItemMapperImpl.java new file mode 100644 index 00000000..ef3846a3 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/mapper/ToDoItemMapperImpl.java @@ -0,0 +1,48 @@ +package com.emobile.springtodo.mapper; + +import com.emobile.springtodo.dto.ToDoItemDto; +import com.emobile.springtodo.entity.ToDoItem; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * Реализация маппера для преобразования сущности ToDoItem в DTO и наоборот. + * + * @author PavelOkhrimchuk + */ +@Component +public class ToDoItemMapperImpl implements ToDoItemMapper { + + /** + * Преобразует DTO в сущность ToDoItem. + * + * @param dto DTO для преобразования. + * @return Сущность ToDoItem. + */ + @Override + public ToDoItem toEntity(ToDoItemDto dto) { + return new ToDoItem( + dto.getId(), + dto.getTitle(), + dto.getDescription(), + dto.isCompleted(), + LocalDateTime.now() + ); + } + + /** + * Преобразует сущность ToDoItem в DTO. + * @param entity Сущность ToDoItem для преобразования. + * @return DTO ToDoItemDto. + */ + @Override + public ToDoItemDto toDto(ToDoItem entity) { + return ToDoItemDto.builder() + .id(entity.getId()) + .title(entity.getTitle()) + .description(entity.getDescription()) + .completed(entity.isCompleted()) + .build(); + } +} diff --git a/src/main/java/com/emobile/springtodo/repository/ToDoRepository.java b/src/main/java/com/emobile/springtodo/repository/ToDoRepository.java new file mode 100644 index 00000000..3f71a9f4 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repository/ToDoRepository.java @@ -0,0 +1,49 @@ +package com.emobile.springtodo.repository; + +import com.emobile.springtodo.entity.ToDoItem; + +import java.util.List; +import java.util.Optional; + +/** + * Репозиторий для работы с задачами ToDo. + * Содержит методы для поиска, сохранения, обновления и удаления задач. + * + * @author PavelOkhrimchuk + */ +public interface ToDoRepository { + + /** + * Получить все задачи с поддержкой пагинации. + * + * @param limit Количество задач для выборки. + * @param offset Смещение для выборки. + * @return Список задач. + */ + List findAll(int limit, int offset); + + /** + * Найти задачу по ID. + * @param id Идентификатор задачи. + * @return Опциональная задача. + */ + Optional findById(Long id); + + /** + * Сохранить новую задачу. + * @param toDoItem Задача, которую нужно сохранить. + */ + void save(ToDoItem toDoItem); + + /** + * Обновить задачу. + * @param toDoItem Задача с обновленными данными. + */ + void update(ToDoItem toDoItem); + + /** + * Удалить задачу по ID. + * @param id Идентификатор задачи. + */ + void deleteById(Long id); +} diff --git a/src/main/java/com/emobile/springtodo/repository/ToDoRepositoryImpl.java b/src/main/java/com/emobile/springtodo/repository/ToDoRepositoryImpl.java new file mode 100644 index 00000000..8970ccf1 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repository/ToDoRepositoryImpl.java @@ -0,0 +1,91 @@ +package com.emobile.springtodo.repository; + +import com.emobile.springtodo.entity.ToDoItem; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * Реализация репозитория для работы с задачами ToDo с использованием JdbcTemplate. + * Реализует методы поиска, сохранения, обновления и удаления задач в базе данных. + * + * @author PavelOkhrimchuk + */ +@Repository +@RequiredArgsConstructor +public class ToDoRepositoryImpl implements ToDoRepository { + + private final JdbcTemplate jdbcTemplate; + + /** + * Маппер для преобразования строки результата SQL запроса в объект ToDoItem. + */ + private final RowMapper rowMapper = (rs, rowNum) -> new ToDoItem( + rs.getLong("id"), + rs.getString("title"), + rs.getString("description"), + rs.getBoolean("completed"), + rs.getTimestamp("created_at").toLocalDateTime() + ); + + /** + * Получить все задачи с поддержкой пагинации. + * + * @param limit Количество задач для выборки. + * @param offset Смещение для выборки. + * @return Список задач. + */ + @Override + public List findAll(int limit, int offset) { + String sql = "SELECT * FROM todo_items ORDER BY created_at DESC LIMIT ? OFFSET ?"; + return jdbcTemplate.query(sql, rowMapper, limit, offset); + } + + /** + * Найти задачу по ID. + * @param id Идентификатор задачи. + * @return Опциональная задача. + */ + @Override + public Optional findById(Long id) { + String sql = "SELECT * FROM todo_items WHERE id = ?"; + List result = jdbcTemplate.query(sql, rowMapper, id); + return result.isEmpty() ? Optional.empty() : Optional.of(result.get(0)); + } + + /** + * Сохранить новую задачу в базе данных. + * @param toDoItem Задача для сохранения. + */ + @Override + public void save(ToDoItem toDoItem) { + String sql = "INSERT INTO todo_items (title, description, completed, created_at) VALUES (?,?,?,?) RETURNING id"; + Long generatedId = jdbcTemplate.queryForObject(sql, Long.class, + toDoItem.getTitle(), toDoItem.getDescription(), toDoItem.isCompleted(), toDoItem.getCreatedAt()); + toDoItem.setId(generatedId); + } + + /** + * Обновить существующую задачу. + * @param toDoItem Задача с обновленными данными. + */ + @Override + public void update(ToDoItem toDoItem) { + String sql = "UPDATE todo_items SET title = ?, description = ?, completed = ? WHERE id = ?"; + jdbcTemplate.update(sql, toDoItem.getTitle(), toDoItem.getDescription(), toDoItem.isCompleted(), toDoItem.getId()); + } + + /** + * Удалить задачу по ID. + * @param id Идентификатор задачи. + */ + @Override + public void deleteById(Long id) { + String sql = "DELETE FROM todo_items WHERE id = ?"; + jdbcTemplate.update(sql, id); + } +} diff --git a/src/main/java/com/emobile/springtodo/service/ToDoService.java b/src/main/java/com/emobile/springtodo/service/ToDoService.java new file mode 100644 index 00000000..14c3f279 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/service/ToDoService.java @@ -0,0 +1,51 @@ +package com.emobile.springtodo.service; + +import com.emobile.springtodo.dto.ToDoItemDto; + +import java.util.List; + +/** + * Сервис для управления задачами ToDo. + * Содержит методы для получения, создания, обновления и удаления задач. + * + * @author PavelOkhrimchuk + */ +public interface ToDoService { + + /** + * Получить все задачи с поддержкой пагинации. + * + * @param limit Количество задач для выборки. + * @param offset Смещение для выборки. + * @return Список задач в виде DTO. + */ + List getAll(int limit, int offset); + + /** + * Получить задачу по ID. + * @param id Идентификатор задачи. + * @return DTO задачи. + */ + ToDoItemDto getById(Long id); + + /** + * Создать новую задачу. + * @param dto DTO задачи для создания. + * @return Созданная задача в виде DTO. + */ + ToDoItemDto create(ToDoItemDto dto); + + /** + * Обновить задачу по ID. + * @param id Идентификатор задачи для обновления. + * @param dto DTO с новыми данными задачи. + * @return Обновленная задача в виде DTO. + */ + ToDoItemDto update(Long id, ToDoItemDto dto); + + /** + * Удалить задачу по ID. + * @param id Идентификатор задачи для удаления. + */ + void delete(Long id); +} diff --git a/src/main/java/com/emobile/springtodo/service/ToDoServiceImpl.java b/src/main/java/com/emobile/springtodo/service/ToDoServiceImpl.java new file mode 100644 index 00000000..a03f6b2f --- /dev/null +++ b/src/main/java/com/emobile/springtodo/service/ToDoServiceImpl.java @@ -0,0 +1,120 @@ +package com.emobile.springtodo.service; + +import com.emobile.springtodo.dto.ToDoItemDto; +import com.emobile.springtodo.entity.ToDoItem; +import com.emobile.springtodo.exception.ToDoNotFoundException; +import com.emobile.springtodo.mapper.ToDoItemMapper; +import com.emobile.springtodo.repository.ToDoRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.stream.Collectors; + +/** + * Реализация сервиса для управления задачами ToDo. + * Включает методы для получения задач, создания, обновления и удаления задач с логированием. + * + * @author PavelOkhrimchuk + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ToDoServiceImpl implements ToDoService { + + private final ToDoRepository toDoRepository; + private final ToDoItemMapper toDoItemMapper; + + /** + * Получить все задачи с пагинацией. + * + * @param limit Количество задач для выборки. + * @param offset Смещение для выборки. + * @return Список задач в виде DTO. + */ + @Override + public List getAll(int limit, int offset) { + log.info("Fetching ToDo items with limit={} and offset={}", limit, offset); + return toDoRepository.findAll(limit, offset) + .stream() + .map(toDoItemMapper::toDto) + .collect(Collectors.toList()); + } + + /** + * Получить задачу по ID с кешированием. + * @param id Идентификатор задачи. + * @return DTO задачи. + */ + @Cacheable(value = "todos", key = "#id") + @Override + public ToDoItemDto getById(Long id) { + log.info("Fetching ToDo item with id={}", id); + ToDoItem item = toDoRepository.findById(id) + .orElseThrow(() -> { + log.warn("ToDo item with id={} not found", id); + return new ToDoNotFoundException(id); + }); + return toDoItemMapper.toDto(item); + } + + /** + * Создание новой задачи. + * @param dto DTO задачи для создания. + * @return Созданная задача в виде DTO. + */ + @Transactional + @Override + public ToDoItemDto create(ToDoItemDto dto) { + log.info("Creating new ToDo item: {}", dto); + ToDoItem entity = toDoItemMapper.toEntity(dto); + toDoRepository.save(entity); + return toDoItemMapper.toDto(entity); + } + + /** + * Обновление задачи по ID с кешированием. + * @param id Идентификатор задачи для обновления. + * @param dto DTO с новыми данными задачи. + * @return Обновленная задача в виде DTO. + */ + @Transactional + @CachePut(value = "todos", key = "#id") + @Override + public ToDoItemDto update(Long id, ToDoItemDto dto) { + log.info("Updating ToDo item with id={}", id); + ToDoItem item = toDoRepository.findById(id) + .orElseThrow(() -> { + log.warn("Cannot update - ToDo item with id={} not found", id); + return new ToDoNotFoundException(id); + }); + + item.setTitle(dto.getTitle()); + item.setDescription(dto.getDescription()); + item.setCompleted(dto.isCompleted()); + + toDoRepository.update(item); + return toDoItemMapper.toDto(item); + } + + /** + * Удаление задачи по ID с кешированием. + * @param id Идентификатор задачи для удаления. + */ + @Transactional + @CacheEvict(value = "todos", key = "#id") + @Override + public void delete(Long id) { + log.info("Deleting ToDo item with id={}", id); + if (toDoRepository.findById(id).isEmpty()) { + log.warn("Cannot delete - ToDo item with id={} not found", id); + throw new ToDoNotFoundException(id); + } + toDoRepository.deleteById(id); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5c3e5461..ffc59196 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,13 @@ spring.application.name=SpringToDo +spring.datasource.url=jdbc:postgresql://localhost:5432/todo_db +spring.datasource.username=todo_user +spring.datasource.password=todo_pass +spring.datasource.driver-class-name=org.postgresql.Driver +spring.flyway.enabled=true +spring.cache.type=redis +spring.data.redis.host=localhost +spring.data.redis.port=6379 +spring.data.redis.timeout=6000 +spring.data.redis.client-type=lettuce +spring.data.redis.password= +spring.data.redis.database=0 diff --git a/src/main/resources/db/migration/V1__Create_initial_schema.sql b/src/main/resources/db/migration/V1__Create_initial_schema.sql new file mode 100644 index 00000000..2c797986 --- /dev/null +++ b/src/main/resources/db/migration/V1__Create_initial_schema.sql @@ -0,0 +1,8 @@ +CREATE TABLE todo_items +( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT, + completed BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/src/test/java/com/emobile/springtodo/controller/BaseIntegrationTest.java b/src/test/java/com/emobile/springtodo/controller/BaseIntegrationTest.java new file mode 100644 index 00000000..af665ba9 --- /dev/null +++ b/src/test/java/com/emobile/springtodo/controller/BaseIntegrationTest.java @@ -0,0 +1,50 @@ +package com.emobile.springtodo.controller; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.PostgreSQLContainer; + + +/** + * Базовый класс для интеграционных тестов. + * Настроены контейнеры для PostgreSQL и Redis. + * + * @author PavelOkhrimchuk + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public abstract class BaseIntegrationTest { + + protected static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>("postgres:latest") + .withDatabaseName("test_db") + .withUsername("test") + .withPassword("test"); + + protected static GenericContainer redisContainer = new GenericContainer<>("redis:latest") + .withExposedPorts(6379); + + @LocalServerPort + protected int port; + + + @BeforeAll + public static void setup() { + postgresContainer.start(); + redisContainer.start(); + + System.setProperty("spring.datasource.url", postgresContainer.getJdbcUrl()); + System.setProperty("spring.datasource.username", postgresContainer.getUsername()); + System.setProperty("spring.datasource.password", postgresContainer.getPassword()); + + System.setProperty("spring.data.redis.host", redisContainer.getHost()); + System.setProperty("spring.data.redis.port", redisContainer.getMappedPort(6379).toString()); + } + + @AfterAll + public static void tearDown() { + postgresContainer.stop(); + redisContainer.stop(); + } +} \ No newline at end of file diff --git a/src/test/java/com/emobile/springtodo/controller/ToDoControllerIntegrationTest.java b/src/test/java/com/emobile/springtodo/controller/ToDoControllerIntegrationTest.java new file mode 100644 index 00000000..cba123fe --- /dev/null +++ b/src/test/java/com/emobile/springtodo/controller/ToDoControllerIntegrationTest.java @@ -0,0 +1,72 @@ +package com.emobile.springtodo.controller; + + +import com.emobile.springtodo.dto.ToDoItemDto; +import com.emobile.springtodo.service.ToDoServiceImpl; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Интеграционные тесты для контроллера ToDo. + * Проверяются основные операции с задачами (создание, получение). + * + * @author PavelOkhrimchuk + */ +@ExtendWith(SpringExtension.class) +@Import(ToDoServiceImpl.class) +class ToDoControllerIntegrationTest extends BaseIntegrationTest { + + @Autowired + private TestRestTemplate restTemplate; + + @Test + void testCreateToDo() { + ToDoItemDto dto = new ToDoItemDto(1L, "New Task", "Description of the task", false); + + + ToDoItemDto response = restTemplate.postForObject("http://localhost:" + port + "/api/todos", dto, ToDoItemDto.class); + + + assertThat(response).isNotNull(); + assertThat(response.getTitle()).isEqualTo(dto.getTitle()); + assertThat(response.getDescription()).isEqualTo(dto.getDescription()); + assertThat(response.isCompleted()).isEqualTo(dto.isCompleted()); + } + + @Test + void testGetAllToDos() { + + ToDoItemDto dto = new ToDoItemDto(1L, "New Task", "Description of the task", false); + restTemplate.postForObject("http://localhost:" + port + "/api/todos", dto, ToDoItemDto.class); + + + ToDoItemDto[] response = restTemplate.getForObject("http://localhost:" + port + "/api/todos", ToDoItemDto[].class); + + + assertThat(response).isNotEmpty(); + } + + @Test + void testGetByIdToDo() { + + ToDoItemDto dto = new ToDoItemDto(1L, "New Task", "Description of the task", false); + ToDoItemDto createdDto = restTemplate.postForObject("http://localhost:" + port + "/api/todos", dto, ToDoItemDto.class); + + + ToDoItemDto response = restTemplate.getForObject("http://localhost:" + port + "/api/todos/" + createdDto.getId(), ToDoItemDto.class); + + + assertThat(response).isNotNull(); + assertThat(response.getTitle()).isEqualTo(dto.getTitle()); + assertThat(response.getDescription()).isEqualTo(dto.getDescription()); + assertThat(response.isCompleted()).isEqualTo(dto.isCompleted()); + } + + +} \ No newline at end of file diff --git a/src/test/java/com/emobile/springtodo/repository/ToDoRepositoryTest.java b/src/test/java/com/emobile/springtodo/repository/ToDoRepositoryTest.java new file mode 100644 index 00000000..266273eb --- /dev/null +++ b/src/test/java/com/emobile/springtodo/repository/ToDoRepositoryTest.java @@ -0,0 +1,66 @@ +package com.emobile.springtodo.repository; + +import com.emobile.springtodo.entity.ToDoItem; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +/** + * Тесты для репозитория ToDo. + * Включает тестирование операций сохранения, обновления и удаления задач. + * + * @author PavelOkhrimchuk + */ +@ExtendWith(MockitoExtension.class) +class ToDoRepositoryTest { + + @Mock + private JdbcTemplate jdbcTemplate; + + @InjectMocks + private ToDoRepositoryImpl toDoRepository; + + + @Test + void testSave() { + ToDoItem toDoItem = new ToDoItem(null, "Title", "Description", false, LocalDateTime.now()); + String sql = "INSERT INTO todo_items (title, description, completed, created_at) VALUES (?,?,?,?) RETURNING id"; + Long generatedId = 1L; + + when(jdbcTemplate.queryForObject(eq(sql), eq(Long.class), any(), any(), any(), any())) + .thenReturn(generatedId); + + toDoRepository.save(toDoItem); + + assertEquals(generatedId, toDoItem.getId()); + verify(jdbcTemplate).queryForObject(eq(sql), eq(Long.class), any(), any(), any(), any()); + } + + @Test + void testUpdate() { + ToDoItem toDoItem = new ToDoItem(1L, "Updated Title", "Updated Description", true, LocalDateTime.now()); + String sql = "UPDATE todo_items SET title = ?, description = ?, completed = ? WHERE id = ?"; + + toDoRepository.update(toDoItem); + + verify(jdbcTemplate).update(eq(sql), any(), any(), any(), eq(toDoItem.getId())); + } + + @Test + void testDeleteById() { + Long id = 1L; + String sql = "DELETE FROM todo_items WHERE id = ?"; + + toDoRepository.deleteById(id); + + verify(jdbcTemplate).update(eq(sql), eq(id)); + } +} diff --git a/src/test/java/com/emobile/springtodo/service/ToDoServiceTest.java b/src/test/java/com/emobile/springtodo/service/ToDoServiceTest.java new file mode 100644 index 00000000..3538a8cc --- /dev/null +++ b/src/test/java/com/emobile/springtodo/service/ToDoServiceTest.java @@ -0,0 +1,133 @@ +package com.emobile.springtodo.service; + + +import com.emobile.springtodo.dto.ToDoItemDto; +import com.emobile.springtodo.entity.ToDoItem; +import com.emobile.springtodo.exception.ToDoNotFoundException; +import com.emobile.springtodo.mapper.ToDoItemMapper; +import com.emobile.springtodo.repository.ToDoRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +/** + * Тесты для сервиса ToDo. + * Включает тестирование всех CRUD операций (получение, создание, обновление, удаление). + * + * @author PavelOkhrimchuk + */ +@ExtendWith(MockitoExtension.class) +class ToDoServiceTest { + + @Mock + private ToDoRepository toDoRepository; + + @Mock + private ToDoItemMapper toDoItemMapper; + + @InjectMocks + private ToDoServiceImpl toDoService; + + @Test + void testGetAll() { + ToDoItem item1 = new ToDoItem(1L, "Task 1", "Description 1", false, LocalDateTime.now()); + ToDoItem item2 = new ToDoItem(2L, "Task 2", "Description 2", true, LocalDateTime.now()); + ToDoItemDto dto1 = new ToDoItemDto(1L, "Task 1", "Description 1", false); + ToDoItemDto dto2 = new ToDoItemDto(1L, "Task 2", "Description 2", true); + + when(toDoRepository.findAll(10, 0)).thenReturn(List.of(item1, item2)); + when(toDoItemMapper.toDto(item1)).thenReturn(dto1); + when(toDoItemMapper.toDto(item2)).thenReturn(dto2); + + List result = toDoService.getAll(10, 0); + + assertEquals(2, result.size()); + assertEquals(dto1, result.get(0)); + assertEquals(dto2, result.get(1)); + + verify(toDoRepository).findAll(10, 0); + verify(toDoItemMapper, times(2)).toDto(any(ToDoItem.class)); + } + + @Test + void testGetById_Success() { + Long id = 1L; + ToDoItem item = new ToDoItem(id, "Task", "Description", false, LocalDateTime.now()); + ToDoItemDto dto = new ToDoItemDto(1L, "Task", "Description", false); + + when(toDoRepository.findById(id)).thenReturn(Optional.of(item)); + when(toDoItemMapper.toDto(item)).thenReturn(dto); + + ToDoItemDto result = toDoService.getById(id); + + assertEquals(dto, result); + verify(toDoRepository).findById(id); + verify(toDoItemMapper).toDto(item); + } + + @Test + void testGetById_NotFound() { + Long id = 1L; + + when(toDoRepository.findById(id)).thenReturn(Optional.empty()); + + assertThrows(ToDoNotFoundException.class, () -> toDoService.getById(id)); + verify(toDoRepository).findById(id); + } + + @Test + void testCreate() { + ToDoItemDto dto = new ToDoItemDto(1L, "New Task", "New Description", false); + ToDoItem entity = new ToDoItem(null, "New Task", "New Description", false, LocalDateTime.now()); + ToDoItemDto savedDto = new ToDoItemDto(1L, "New Task", "New Description", false); + + when(toDoItemMapper.toEntity(dto)).thenReturn(entity); + when(toDoItemMapper.toDto(any(ToDoItem.class))).thenReturn(savedDto); + + doAnswer(invocation -> { + entity.setId(1L); + return null; + }).when(toDoRepository).save(entity); + + ToDoItemDto result = toDoService.create(dto); + + assertEquals(savedDto, result); + verify(toDoRepository).save(entity); + verify(toDoItemMapper).toEntity(dto); + verify(toDoItemMapper).toDto(any(ToDoItem.class)); + } + + + @Test + void testDelete_Success() { + Long id = 1L; + ToDoItem item = new ToDoItem(id, "Task", "Description", false, LocalDateTime.now()); + + when(toDoRepository.findById(id)).thenReturn(Optional.of(item)); + + toDoService.delete(id); + + verify(toDoRepository).findById(id); + verify(toDoRepository).deleteById(id); + } + + @Test + void testDelete_NotFound() { + Long id = 1L; + + when(toDoRepository.findById(id)).thenReturn(Optional.empty()); + + assertThrows(ToDoNotFoundException.class, () -> toDoService.delete(id)); + verify(toDoRepository).findById(id); + } +}