From 5e0573f8caf9545a7af02af31893eddf69ac7af8 Mon Sep 17 00:00:00 2001 From: yuzhakovmihail Date: Thu, 28 Aug 2025 00:09:41 +0300 Subject: [PATCH 1/7] =?UTF-8?q?JpaRepository=20=D0=B7=D0=B0=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=20=D0=BD=D0=B0=20JDBCTemplate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 18 +++++- .../springtodo/SpringToDoApplication.java | 2 - .../controllers/ControllerAdvice.java | 16 ++++++ .../controllers/TaskController.java | 47 +++++++++++++++ .../emobile/springtodo/dto/ErrorResponse.java | 27 +++++++++ .../com/emobile/springtodo/dto/TaskDTO.java | 14 +++++ .../exceptions/TaskNotFoundException.java | 7 +++ .../com/emobile/springtodo/model/Task.java | 20 +++++++ .../repositories/JdbcTemplateTaskDaoImpl.java | 57 +++++++++++++++++++ .../springtodo/repositories/TaskDao.java | 15 +++++ .../springtodo/repositories/TaskMapper.java | 23 ++++++++ .../springtodo/services/TaskService.java | 57 +++++++++++++++++++ src/main/resources/application.properties | 1 - src/main/resources/application.yaml | 8 +++ 14 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/emobile/springtodo/controllers/ControllerAdvice.java create mode 100644 src/main/java/com/emobile/springtodo/controllers/TaskController.java create mode 100644 src/main/java/com/emobile/springtodo/dto/ErrorResponse.java create mode 100644 src/main/java/com/emobile/springtodo/dto/TaskDTO.java create mode 100644 src/main/java/com/emobile/springtodo/exceptions/TaskNotFoundException.java create mode 100644 src/main/java/com/emobile/springtodo/model/Task.java create mode 100644 src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java create mode 100644 src/main/java/com/emobile/springtodo/repositories/TaskDao.java create mode 100644 src/main/java/com/emobile/springtodo/repositories/TaskMapper.java create mode 100644 src/main/java/com/emobile/springtodo/services/TaskService.java delete mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/application.yaml diff --git a/pom.xml b/pom.xml index 03bc446d..169b3419 100644 --- a/pom.xml +++ b/pom.xml @@ -23,7 +23,23 @@ org.springframework.boot spring-boot-starter - + + org.springframework.boot + spring-boot-starter-data-jdbc + + + org.springframework.boot + spring-boot-starter-web + + + org.postgresql + postgresql + runtime + + + org.projectlombok + lombok + org.springframework.boot spring-boot-starter-test diff --git a/src/main/java/com/emobile/springtodo/SpringToDoApplication.java b/src/main/java/com/emobile/springtodo/SpringToDoApplication.java index 41980514..e50bb340 100644 --- a/src/main/java/com/emobile/springtodo/SpringToDoApplication.java +++ b/src/main/java/com/emobile/springtodo/SpringToDoApplication.java @@ -5,9 +5,7 @@ @SpringBootApplication public class SpringToDoApplication { - public static void main(String[] args) { SpringApplication.run(SpringToDoApplication.class, args); } - } diff --git a/src/main/java/com/emobile/springtodo/controllers/ControllerAdvice.java b/src/main/java/com/emobile/springtodo/controllers/ControllerAdvice.java new file mode 100644 index 00000000..0a8f8b6c --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controllers/ControllerAdvice.java @@ -0,0 +1,16 @@ +package com.emobile.springtodo.controllers; + +import com.emobile.springtodo.exceptions.TaskNotFoundException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import com.emobile.springtodo.dto.ErrorResponse; + +@org.springframework.web.bind.annotation.ControllerAdvice +public class ControllerAdvice { + @ExceptionHandler(TaskNotFoundException.class) + public ResponseEntity TaskNotFoundExceptionHandler(TaskNotFoundException exception) { + return ResponseEntity + .badRequest() + .body(new ErrorResponse("ACCESS_ERROR", exception.getMessage())); + } +} 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..5b21af1a --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controllers/TaskController.java @@ -0,0 +1,47 @@ +package com.emobile.springtodo.controllers; + +import com.emobile.springtodo.dto.TaskDTO; +import com.emobile.springtodo.model.Task; +import com.emobile.springtodo.services.TaskService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + + +import java.util.List; + +@RestController +@RequestMapping("/api/tasks") +public class TaskController { + private final TaskService service; + + public TaskController(TaskService service) { + this.service = service; + } + + @GetMapping + public List getAllTasks() { + return service.getAllTasks(); + } + + @GetMapping("/{id}") + public ResponseEntity getTaskById(@PathVariable Long id) { + return ResponseEntity.ok(service.get(id)); + } + + @PostMapping + public Task createTask(@RequestBody TaskDTO taskDTO) { + return service.create(taskDTO); + } + + @PutMapping("/{id}") + public ResponseEntity updateTask(@PathVariable Long id, @RequestBody TaskDTO taskDetails) { + Task updatedTask = service.updateTask(id, taskDetails); + return ResponseEntity.ok(updatedTask); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteTask(@PathVariable Long id) { + service.deleteTask(id); + return ResponseEntity.ok("Task delete successfully"); + } +} 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..4da5b3c9 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/ErrorResponse.java @@ -0,0 +1,27 @@ +package com.emobile.springtodo.dto; + +public class ErrorResponse { + private String code; + private String message; + + public ErrorResponse(String code, String message) { + this.code = code; + this.message = message; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} 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..dde3fc3c --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/TaskDTO.java @@ -0,0 +1,14 @@ +package com.emobile.springtodo.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +@AllArgsConstructor +public class TaskDTO { + private String title; + private String description; + private boolean isCompleted; +} diff --git a/src/main/java/com/emobile/springtodo/exceptions/TaskNotFoundException.java b/src/main/java/com/emobile/springtodo/exceptions/TaskNotFoundException.java new file mode 100644 index 00000000..0d0de79b --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exceptions/TaskNotFoundException.java @@ -0,0 +1,7 @@ +package com.emobile.springtodo.exceptions; + +public class TaskNotFoundException extends RuntimeException { + public TaskNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/emobile/springtodo/model/Task.java b/src/main/java/com/emobile/springtodo/model/Task.java new file mode 100644 index 00000000..807616ea --- /dev/null +++ b/src/main/java/com/emobile/springtodo/model/Task.java @@ -0,0 +1,20 @@ +package com.emobile.springtodo.model; + +import lombok.*; +import org.springframework.data.annotation.Id; + +import java.time.LocalDateTime; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class Task { + @Id + private Long id; + private String title; + private String description; + private boolean completed; + private LocalDateTime created_at; + private LocalDateTime updated_at; + +} diff --git a/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java b/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java new file mode 100644 index 00000000..45a56f65 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java @@ -0,0 +1,57 @@ +package com.emobile.springtodo.repositories; + +import com.emobile.springtodo.model.Task; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class JdbcTemplateTaskDaoImpl implements TaskDao { + + private final JdbcTemplate jdbcTemplate; + + @Override + public Optional> findAll() { + String SQL = "SELECT * FROM tasks"; + try { + List task = jdbcTemplate.query(SQL, new TaskMapper()); + return Optional.of(task); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + + @Override + public Optional findById(Long id) { + String SQL = "SELECT * FROM tasks WHERE id = ?"; + try { + Task task = jdbcTemplate.queryForObject(SQL, new TaskMapper(), id); + return Optional.ofNullable(task); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + + @Override + public void save(Task task) { + String SQL = "INSERT INTO tasks (title, description, completed, created_at, updated_at) VALUES (?, ?, ?, ?, ?)"; + jdbcTemplate.update(SQL, task.getTitle(), task.getDescription(), task.isCompleted(), task.getCreated_at(), task.getUpdated_at()); + } + + @Override + public void delete(Task task) { + String SQL = "DELETE FROM tasks WHERE id = ?"; + jdbcTemplate.update(SQL, task.getId()); + } + + @Override + public void update(Long id, Task task) { + String SQL = "UPDATE tasks SET title = ?, description = ?, completed = ? created_at = ? updated_at = ? WHERE id = ?"; + jdbcTemplate.update(SQL, task.getTitle(), task.getDescription(), task.isCompleted(), task.getCreated_at(), task.getUpdated_at(), task.getId()); + } +} \ No newline at end of file diff --git a/src/main/java/com/emobile/springtodo/repositories/TaskDao.java b/src/main/java/com/emobile/springtodo/repositories/TaskDao.java new file mode 100644 index 00000000..9f50007d --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repositories/TaskDao.java @@ -0,0 +1,15 @@ +package com.emobile.springtodo.repositories; + +import com.emobile.springtodo.model.Task; + +import java.util.List; +import java.util.Optional; + + +public interface TaskDao { + Optional> findAll(); + Optional findById(Long id); + void save(Task task); + void delete(Task task); + void update(Long id, Task task); +} diff --git a/src/main/java/com/emobile/springtodo/repositories/TaskMapper.java b/src/main/java/com/emobile/springtodo/repositories/TaskMapper.java new file mode 100644 index 00000000..3c25b71f --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repositories/TaskMapper.java @@ -0,0 +1,23 @@ +package com.emobile.springtodo.repositories; + +import com.emobile.springtodo.model.Task; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDateTime; + +public class TaskMapper implements RowMapper { + + @Override + public Task mapRow(ResultSet rs, int rowNum) throws SQLException { + Task task = new Task(); + task.setId(rs.getLong("id")); + task.setTitle(rs.getString("title")); + task.setDescription(rs.getString("description")); + task.setCreated_at(rs.getTimestamp("created_at").toLocalDateTime()); + task.setUpdated_at(rs.getTimestamp("updated_at").toLocalDateTime()); + task.setCompleted(rs.getBoolean("completed")); + return task; + } +} 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..c8172385 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/services/TaskService.java @@ -0,0 +1,57 @@ +package com.emobile.springtodo.services; + +import com.emobile.springtodo.dto.TaskDTO; +import com.emobile.springtodo.exceptions.TaskNotFoundException; +import com.emobile.springtodo.model.Task; +import com.emobile.springtodo.repositories.TaskDao; +import org.springframework.stereotype.Service; + + +import java.time.LocalDateTime; +import java.util.List; + +@Service +public class TaskService { + private final TaskDao taskRepository; + + public TaskService(TaskDao taskRepository) { + this.taskRepository = taskRepository; + } + + public List getAllTasks() { + return taskRepository.findAll() + .orElseThrow(() -> new TaskNotFoundException("Tasks not found")); + } + + public Task get(Long taskId) { + return taskRepository.findById(taskId) + .orElseThrow(() -> new TaskNotFoundException("Task not found with id " + taskId)); + } + + public Task create(TaskDTO taskDTO) { + Task task = new Task(); + task.setDescription(taskDTO.getDescription()); + task.setTitle(taskDTO.getTitle()); + task.setCompleted(taskDTO.isCompleted()); + task.setCreated_at(LocalDateTime.now()); + task.setUpdated_at(LocalDateTime.now()); + taskRepository.save(task); + return task; + } + + public Task updateTask(Long id, TaskDTO taskDetails) { + Task task = taskRepository.findById(id) + .orElseThrow(() -> new TaskNotFoundException("Task not found with id " + id)); + task.setTitle(taskDetails.getTitle()); + task.setDescription(taskDetails.getDescription()); + task.setCompleted(taskDetails.isCompleted()); + taskRepository.update(id, task); + return task; + } + + public void deleteTask(Long id) { + Task task = taskRepository.findById(id) + .orElseThrow(() -> new TaskNotFoundException("Task not found with id " + id)); + taskRepository.delete(task); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 5c3e5461..00000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=SpringToDo diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 00000000..96009e4d --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,8 @@ +spring: + application: + name: SpringToDo + datasource: + url: jdbc:postgresql://localhost:5432/taskmanager + username: postgres + password: 10031998 + driver-class-name: org.postgresql.Driver From a2fed91fb153fc335cebaa488a4f11728d051142 Mon Sep 17 00:00:00 2001 From: yuzhakovmihail Date: Thu, 28 Aug 2025 00:11:20 +0300 Subject: [PATCH 2/7] fixed update repo operation --- .../springtodo/repositories/JdbcTemplateTaskDaoImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java b/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java index 45a56f65..98e07c52 100644 --- a/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java +++ b/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java @@ -51,7 +51,7 @@ public void delete(Task task) { @Override public void update(Long id, Task task) { - String SQL = "UPDATE tasks SET title = ?, description = ?, completed = ? created_at = ? updated_at = ? WHERE id = ?"; + String SQL = "UPDATE tasks SET title = ?, description = ?, completed = ?, created_at = ?, updated_at = ? WHERE id = ?"; jdbcTemplate.update(SQL, task.getTitle(), task.getDescription(), task.isCompleted(), task.getCreated_at(), task.getUpdated_at(), task.getId()); } } \ No newline at end of file From 1663b833c9ce0e7ae9960f04307522564a39cba6 Mon Sep 17 00:00:00 2001 From: yuzhakovmihail Date: Mon, 1 Sep 2025 22:08:31 +0300 Subject: [PATCH 3/7] task mappers --- pom.xml | 27 +++ .../springtodo/configuration/RedisConfig.java | 57 ++++++ .../controllers/TaskController.java | 6 +- .../springtodo/dto/TaskCreateRequest.java | 16 ++ .../emobile/springtodo/dto/TaskResponse.java | 21 +++ .../springtodo/dto/TaskUpdateRequest.java | 16 ++ .../com/emobile/springtodo/model/Task.java | 1 - .../repositories/JdbcTemplateTaskDaoImpl.java | 4 +- .../{TaskMapper.java => RowMapperImpl.java} | 3 +- .../springtodo/services/MetricsService.java | 168 ++++++++++++++++++ .../services/MetricsServiceInterface.java | 12 ++ .../springtodo/services/TaskService.java | 39 +++- .../services/TaskServiceInterface.java | 20 +++ .../emobile/springtodo/utils/TaskMapper.java | 40 +++++ src/main/resources/application.yaml | 10 ++ .../db/changelog/001-create-table.yaml | 44 +++++ .../db/changelog/002-fill-table.yaml | 89 ++++++++++ .../db/changelog/db.changelog-master.yaml | 5 + 18 files changed, 564 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/emobile/springtodo/configuration/RedisConfig.java create mode 100644 src/main/java/com/emobile/springtodo/dto/TaskCreateRequest.java create mode 100644 src/main/java/com/emobile/springtodo/dto/TaskResponse.java create mode 100644 src/main/java/com/emobile/springtodo/dto/TaskUpdateRequest.java rename src/main/java/com/emobile/springtodo/repositories/{TaskMapper.java => RowMapperImpl.java} (89%) create mode 100644 src/main/java/com/emobile/springtodo/services/MetricsService.java create mode 100644 src/main/java/com/emobile/springtodo/services/MetricsServiceInterface.java create mode 100644 src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java create mode 100644 src/main/java/com/emobile/springtodo/utils/TaskMapper.java create mode 100644 src/main/resources/db/changelog/001-create-table.yaml create mode 100644 src/main/resources/db/changelog/002-fill-table.yaml create mode 100644 src/main/resources/db/changelog/db.changelog-master.yaml diff --git a/pom.xml b/pom.xml index 169b3419..954b3e2e 100644 --- a/pom.xml +++ b/pom.xml @@ -31,6 +31,14 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-cache + + + org.springframework.boot + spring-boot-starter-data-redis + org.postgresql postgresql @@ -45,6 +53,25 @@ spring-boot-starter-test test + + org.liquibase + liquibase-core + + + + io.micrometer + micrometer-core + 1.16.0-M2 + + + jakarta.validation + jakarta.validation-api + + + org.mapstruct + mapstruct + 1.6.3 + 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..cdf0faca --- /dev/null +++ b/src/main/java/com/emobile/springtodo/configuration/RedisConfig.java @@ -0,0 +1,57 @@ +package com.emobile.springtodo.configuration; + +import org.springframework.beans.factory.annotation.Value; +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; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; + +@Configuration +@EnableCaching +public class RedisConfig { + + @Value("${spring.redis.host}") + private String redisHost; + + @Value("${spring.redis.port}") + private int redisPort; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisHost, redisPort); + return new LettuceConnectionFactory(config); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + return template; + } + + @Bean + public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofHours(1)) + .disableCachingNullValues() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(config) + .build(); + } +} diff --git a/src/main/java/com/emobile/springtodo/controllers/TaskController.java b/src/main/java/com/emobile/springtodo/controllers/TaskController.java index 5b21af1a..a064a0ad 100644 --- a/src/main/java/com/emobile/springtodo/controllers/TaskController.java +++ b/src/main/java/com/emobile/springtodo/controllers/TaskController.java @@ -2,7 +2,7 @@ import com.emobile.springtodo.dto.TaskDTO; import com.emobile.springtodo.model.Task; -import com.emobile.springtodo.services.TaskService; +import com.emobile.springtodo.services.TaskServiceInterface; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -12,9 +12,9 @@ @RestController @RequestMapping("/api/tasks") public class TaskController { - private final TaskService service; + private final TaskServiceInterface service; - public TaskController(TaskService service) { + public TaskController(TaskServiceInterface service) { this.service = service; } diff --git a/src/main/java/com/emobile/springtodo/dto/TaskCreateRequest.java b/src/main/java/com/emobile/springtodo/dto/TaskCreateRequest.java new file mode 100644 index 00000000..2ce42a40 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/TaskCreateRequest.java @@ -0,0 +1,16 @@ +package com.emobile.springtodo.dto; + +import jakarta.validation.constraints.Size; +import lombok.Data; +import jakarta.validation.constraints.NotBlank; + +@Data +public class TaskCreateRequest { + + @NotBlank(message = "Title is required") + @Size(min = 1, max = 255, message = "Title must be between 1 and 255 characters") + private String title; + + @Size(max = 2000, message = "Description must not exceed 2000 characters") + private String description; +} \ No newline at end of file diff --git a/src/main/java/com/emobile/springtodo/dto/TaskResponse.java b/src/main/java/com/emobile/springtodo/dto/TaskResponse.java new file mode 100644 index 00000000..3a7642d7 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/TaskResponse.java @@ -0,0 +1,21 @@ +package com.emobile.springtodo.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TaskResponse { + private Long id; + private String title; + private String description; + private Boolean completed; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/emobile/springtodo/dto/TaskUpdateRequest.java b/src/main/java/com/emobile/springtodo/dto/TaskUpdateRequest.java new file mode 100644 index 00000000..c74139b8 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/TaskUpdateRequest.java @@ -0,0 +1,16 @@ +package com.emobile.springtodo.dto; + +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class TaskUpdateRequest { + + @Size(min = 1, max = 255, message = "Title must be between 1 and 255 characters") + private String title; + + @Size(max = 2000, message = "Description must not exceed 2000 characters") + private String description; + + private Boolean completed; +} diff --git a/src/main/java/com/emobile/springtodo/model/Task.java b/src/main/java/com/emobile/springtodo/model/Task.java index 807616ea..ee3074a7 100644 --- a/src/main/java/com/emobile/springtodo/model/Task.java +++ b/src/main/java/com/emobile/springtodo/model/Task.java @@ -16,5 +16,4 @@ public class Task { private boolean completed; private LocalDateTime created_at; private LocalDateTime updated_at; - } diff --git a/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java b/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java index 98e07c52..39df125a 100644 --- a/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java +++ b/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java @@ -19,7 +19,7 @@ public class JdbcTemplateTaskDaoImpl implements TaskDao { public Optional> findAll() { String SQL = "SELECT * FROM tasks"; try { - List task = jdbcTemplate.query(SQL, new TaskMapper()); + List task = jdbcTemplate.query(SQL, new RowMapperImpl()); return Optional.of(task); } catch (EmptyResultDataAccessException e) { return Optional.empty(); @@ -30,7 +30,7 @@ public Optional> findAll() { public Optional findById(Long id) { String SQL = "SELECT * FROM tasks WHERE id = ?"; try { - Task task = jdbcTemplate.queryForObject(SQL, new TaskMapper(), id); + Task task = jdbcTemplate.queryForObject(SQL, new RowMapperImpl(), id); return Optional.ofNullable(task); } catch (EmptyResultDataAccessException e) { return Optional.empty(); diff --git a/src/main/java/com/emobile/springtodo/repositories/TaskMapper.java b/src/main/java/com/emobile/springtodo/repositories/RowMapperImpl.java similarity index 89% rename from src/main/java/com/emobile/springtodo/repositories/TaskMapper.java rename to src/main/java/com/emobile/springtodo/repositories/RowMapperImpl.java index 3c25b71f..b6fcb75a 100644 --- a/src/main/java/com/emobile/springtodo/repositories/TaskMapper.java +++ b/src/main/java/com/emobile/springtodo/repositories/RowMapperImpl.java @@ -5,9 +5,8 @@ import java.sql.ResultSet; import java.sql.SQLException; -import java.time.LocalDateTime; -public class TaskMapper implements RowMapper { +public class RowMapperImpl implements RowMapper { @Override public Task mapRow(ResultSet rs, int rowNum) throws SQLException { diff --git a/src/main/java/com/emobile/springtodo/services/MetricsService.java b/src/main/java/com/emobile/springtodo/services/MetricsService.java new file mode 100644 index 00000000..075d5a98 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/services/MetricsService.java @@ -0,0 +1,168 @@ +package com.emobile.springtodo.services; + +import com.emobile.springtodo.model.Task; +import io.micrometer.core.instrument.*; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +@RequiredArgsConstructor +@Slf4j +public class MetricsService implements MetricsServiceInterface { + private final MeterRegistry meterRegistry; + + // Счетчики + private Counter createdTasksCounter; + private Counter completedTasksCounter; + private Counter deletedTasksCounter; + + // Таймеры + private Timer taskCreationTimer; + private Timer taskCompletionTimer; + + // Гейджи + private final AtomicLong activeTasksCount = new AtomicLong(0); + private final AtomicLong completedTasksTotal = new AtomicLong(0); + + // Distribution summaries + private DistributionSummary taskTitleLengthSummary; + + @PostConstruct + public void init() { + initializeCounters(); + initializeTimers(); + initializeGauges(); + initializeDistributionSummaries(); + } + private void initializeCounters() { + createdTasksCounter = Counter.builder("todo.tasks.created") + .description("Total number of created tasks") + .tag("application", "todo-service") + .register(meterRegistry); + + completedTasksCounter = Counter.builder("todo.tasks.completed") + .description("Total number of completed tasks") + .tag("application", "todo-service") + .register(meterRegistry); + + deletedTasksCounter = Counter.builder("todo.tasks.deleted") + .description("Total number of deleted tasks") + .tag("application", "todo-service") + .register(meterRegistry); + } + + private void initializeTimers() { + taskCreationTimer = Timer.builder("todo.tasks.creation.time") + .description("Time taken to create a task") + .tag("application", "todo-service") + .publishPercentiles(0.5, 0.95, 0.99) + .register(meterRegistry); + + taskCompletionTimer = Timer.builder("todo.tasks.completion.time") + .description("Time taken to complete a task") + .tag("application", "todo-service") + .publishPercentiles(0.5, 0.95, 0.99) + .register(meterRegistry); + } + + private void initializeGauges() { + Gauge.builder("todo.tasks.active.count", activeTasksCount, AtomicLong::get) + .description("Current number of active tasks") + .tag("application", "todo-service") + .register(meterRegistry); + + Gauge.builder("todo.tasks.completed.total", completedTasksTotal, AtomicLong::get) + .description("Total number of completed tasks (gauge)") + .tag("application", "todo-service") + .register(meterRegistry); + } + + // Методы для работы с конкретными задачами + public void recordTaskMetrics(Task task) { + if (task.getTitle() != null) { + recordTitleLength(task.getTitle().length()); + } + + if (Boolean.TRUE.equals(task.getCompleted())) { + incrementCompletedTasks(); + } + } + + // Метод для сброса метрик (для тестов) + public void reset() { + activeTasksCount.set(0); + completedTasksTotal.set(0); + log.info("Metrics reset performed"); + } + + + private void initializeDistributionSummaries() { + taskTitleLengthSummary = DistributionSummary.builder("todo.tasks.title.length") + .description("Distribution of task title lengths") + .tag("application", "todo-service") + .register(meterRegistry); + } + + public void recordTitleLength(int length) { + taskTitleLengthSummary.record(length); + log.debug("Title length recorded: {}", length); + } + + @Override + public void incrementCreatedTasks() { + createdTasksCounter.increment(); + activeTasksCount.incrementAndGet(); + log.debug("Created tasks counter incremented"); + } + + @Override + public void incrementCompletedTasks() { + completedTasksCounter.increment(); + completedTasksTotal.incrementAndGet(); + activeTasksCount.decrementAndGet(); + log.debug("Completed tasks counter incremented"); + } + + @Override + public void incrementDeletedTasks() { + deletedTasksCounter.increment(); + activeTasksCount.decrementAndGet(); + log.debug("Deleted tasks counter incremented"); + } + + @Override + public void recordTaskCompletionTime(long milliseconds) { + taskCompletionTimer.record(milliseconds, TimeUnit.MILLISECONDS); + log.debug("Task completion time recorded: {} ms", milliseconds); + } + + @Override + public void recordTaskCreationTime(long milliseconds) { + taskCreationTimer.record(milliseconds, TimeUnit.MILLISECONDS); + log.debug("Task creation time recorded: {} ms", milliseconds); + } + + @Override + public Map getMetricsSnapshot() { + Map snapshot = new HashMap<>(); + + snapshot.put("createdTasks", createdTasksCounter.count()); + snapshot.put("completedTasks", completedTasksCounter.count()); + snapshot.put("deletedTasks", deletedTasksCounter.count()); + snapshot.put("activeTasks", activeTasksCount.get()); + snapshot.put("totalCompleted", completedTasksTotal.get()); + + // Добавляем перцентили из таймеров + snapshot.put("creationTimeP50", taskCreationTimer.percentile(0.5, TimeUnit.MILLISECONDS)); + snapshot.put("creationTimeP95", taskCreationTimer.percentile(0.95, TimeUnit.MILLISECONDS)); + snapshot.put("completionTimeP50", taskCompletionTimer.percentile(0.5, TimeUnit.MILLISECONDS)); + snapshot.put("completionTimeP95", taskCompletionTimer.percentile(0.95, TimeUnit.MILLISECONDS)); + + return snapshot; + } +} diff --git a/src/main/java/com/emobile/springtodo/services/MetricsServiceInterface.java b/src/main/java/com/emobile/springtodo/services/MetricsServiceInterface.java new file mode 100644 index 00000000..0e012fe4 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/services/MetricsServiceInterface.java @@ -0,0 +1,12 @@ +package com.emobile.springtodo.services; + +import java.util.Map; + +public interface MetricsServiceInterface { + void incrementCreatedTasks(); + void incrementCompletedTasks(); + void incrementDeletedTasks(); + void recordTaskCompletionTime(long milliseconds); + void recordTaskCreationTime(long milliseconds); + Map getMetricsSnapshot(); +} \ No newline at end of file diff --git a/src/main/java/com/emobile/springtodo/services/TaskService.java b/src/main/java/com/emobile/springtodo/services/TaskService.java index c8172385..d1f03a49 100644 --- a/src/main/java/com/emobile/springtodo/services/TaskService.java +++ b/src/main/java/com/emobile/springtodo/services/TaskService.java @@ -1,9 +1,14 @@ package com.emobile.springtodo.services; +import com.emobile.springtodo.dto.TaskCreateRequest; import com.emobile.springtodo.dto.TaskDTO; +import com.emobile.springtodo.dto.TaskResponse; import com.emobile.springtodo.exceptions.TaskNotFoundException; import com.emobile.springtodo.model.Task; import com.emobile.springtodo.repositories.TaskDao; +import com.emobile.springtodo.utils.TaskMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -11,24 +16,44 @@ import java.util.List; @Service -public class TaskService { +@Slf4j +@RequiredArgsConstructor +public class TaskService implements TaskServiceInterface { private final TaskDao taskRepository; + private final TaskMapper taskMapper; + private final MetricsService metricsService; - public TaskService(TaskDao taskRepository) { - this.taskRepository = taskRepository; - } - + @Override public List getAllTasks() { return taskRepository.findAll() .orElseThrow(() -> new TaskNotFoundException("Tasks not found")); } + @Override public Task get(Long taskId) { return taskRepository.findById(taskId) .orElseThrow(() -> new TaskNotFoundException("Task not found with id " + taskId)); } - public Task create(TaskDTO taskDTO) { + @Override + public TaskResponse create(TaskCreateRequest taskCreateRequest) { + long startTime = System.currentTimeMillis(); + + try { + log.info("Creating new todo: {}", request.getTitle()); + Task task = taskMapper.toEntity(taskCreateRequest); + taskRepository.save(task); + + metricsService.incrementCreatedTasks(); + metricsService.recordTaskMetrics(task); + + return taskMapper.toResponse(task); + } finally { + long duration = System.currentTimeMillis() - startTime; + metricsService.recordTaskCreationTime(duration); + } + + Task task = new Task(); task.setDescription(taskDTO.getDescription()); task.setTitle(taskDTO.getTitle()); @@ -39,6 +64,7 @@ public Task create(TaskDTO taskDTO) { return task; } + @Override public Task updateTask(Long id, TaskDTO taskDetails) { Task task = taskRepository.findById(id) .orElseThrow(() -> new TaskNotFoundException("Task not found with id " + id)); @@ -49,6 +75,7 @@ public Task updateTask(Long id, TaskDTO taskDetails) { return task; } + @Override public void deleteTask(Long id) { Task task = taskRepository.findById(id) .orElseThrow(() -> new TaskNotFoundException("Task not found with id " + id)); diff --git a/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java b/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java new file mode 100644 index 00000000..cf10d73c --- /dev/null +++ b/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java @@ -0,0 +1,20 @@ +package com.emobile.springtodo.services; + +import com.emobile.springtodo.dto.TaskCreateRequest; +import com.emobile.springtodo.dto.TaskDTO; +import com.emobile.springtodo.dto.TaskResponse; +import com.emobile.springtodo.model.Task; + +import java.util.List; + +public interface TaskServiceInterface { + public List getAllTasks(); + public Task get(Long taskId); + public Task create(TaskDTO taskDTO); + + TaskResponse create(TaskCreateRequest taskCreateRequest); + + public Task updateTask(Long id, TaskDTO taskDetails); + public void deleteTask(Long id); + +} diff --git a/src/main/java/com/emobile/springtodo/utils/TaskMapper.java b/src/main/java/com/emobile/springtodo/utils/TaskMapper.java new file mode 100644 index 00000000..4a28ac28 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/utils/TaskMapper.java @@ -0,0 +1,40 @@ +package com.emobile.springtodo.utils; +import com.emobile.springtodo.dto.TaskCreateRequest; +import com.emobile.springtodo.dto.TaskResponse; +import com.emobile.springtodo.dto.TaskUpdateRequest; +import com.emobile.springtodo.model.Task; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; +import org.mapstruct.factory.Mappers; +import org.springframework.data.domain.Page; + +import java.util.List; + +public interface TaskMapper { + TaskMapper INSTANCE = Mappers.getMapper(TaskMapper.class); + + // Маппинг из Entity в ResponseDTO + @Mapping(source = "created_at", target = "createdAt") + @Mapping(source = "updated_at", target = "updatedAt") + TaskResponse toResponse(Task task); + + List toResponseList(List tasks); + + // Маппинг из CreateRequest DTO в Entity + @Mapping(target = "id", ignore = true) + @Mapping(target = "completed", constant = "false") + @Mapping(target = "created_at", expression = "java(java.time.LocalDateTime.now())") + @Mapping(target = "updated_at", expression = "java(java.time.LocalDateTime.now())") + Task toEntity(TaskCreateRequest request); + + // Маппинг из UpdateRequest DTO в Entity (обновление существующей сущности) + @Mapping(target = "id", ignore = true) + @Mapping(target = "created_at", ignore = true) + @Mapping(target = "updated_at", expression = "java(java.time.LocalDateTime.now())") + void updateEntityFromRequest(TaskUpdateRequest request, @MappingTarget Task task); + + // Маппинг для пагинации + default Page toResponsePage(Page taskPage) { + return taskPage.map(this::toResponse); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 96009e4d..9ad37496 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,4 +1,14 @@ spring: + redis: + host: localhost + port: 6379 + password: Qwerty123 + database: 0 + embedded: false + cache: + type: redis + time-to-live: 3600000 + cache-null-values: false application: name: SpringToDo datasource: diff --git a/src/main/resources/db/changelog/001-create-table.yaml b/src/main/resources/db/changelog/001-create-table.yaml new file mode 100644 index 00000000..9c200c04 --- /dev/null +++ b/src/main/resources/db/changelog/001-create-table.yaml @@ -0,0 +1,44 @@ +databaseChangeLog: + - changeSet: + id: 1 + author: yuzhakovmikhail + changes: + - createTable: + tableName: tasks + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: title + type: VARCHAR(255) + constraints: + nullable: false + unique: true + - column: + name: description + type: VARCHAR(2000) + constraints: + nullable: false + unique: true + - column: + name: completed + type: BOOLEAN + constraints: + nullable: false + - column: + name: created_at + type: DATE + constraints: + nullable: false + remarks: Дата создание задачи + - column: + name: updated_at + type: DATE + constraints: + nullable: false + remarks: Дата обновления задачи \ No newline at end of file diff --git a/src/main/resources/db/changelog/002-fill-table.yaml b/src/main/resources/db/changelog/002-fill-table.yaml new file mode 100644 index 00000000..0d3b48dc --- /dev/null +++ b/src/main/resources/db/changelog/002-fill-table.yaml @@ -0,0 +1,89 @@ +databaseChangeLog: + - changeSet: + id: 2 + author: yuzhakovmikhail + changes: + - insert: + tableName: tasks + columns: + - column: + name: id + value: "1" + - column: + name: title + value: "Новая задача № 1" + - column: + name: description + value: "Необходимо организовать..." + - column: + name: completed + value: "false" + - column: + name: created_at + value: "2025-05-25" + - column: + name: updated_at + value: "2025-06-25" + - insert: + tableName: tasks + columns: + - column: + name: id + value: "2" + - column: + name: title + value: "Новая задача № 2" + - column: + name: description + value: "Необходимо спланировать..." + - column: + name: completed + value: "false" + - column: + name: created_at + value: "2024-05-25" + - column: + name: updated_at + value: "2024-06-25" + - insert: + tableName: tasks + columns: + - column: + name: id + value: "3" + - column: + name: title + value: "Новая задача № 3" + - column: + name: description + value: "Необходимо удалить..." + - column: + name: completed + value: "true" + - column: + name: created_at + value: "2025-07-28" + - column: + name: updated_at + value: "2025-07-30" + - insert: + tableName: tasks + columns: + - column: + name: id + value: "4" + - column: + name: title + value: "Новая задача № 4" + - column: + name: description + value: "Необходимо обновить..." + - column: + name: completed + value: "false" + - column: + name: created_at + value: "2025-07-23" + - column: + name: updated_at + value: "2025-07-27" \ 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..769e975e --- /dev/null +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -0,0 +1,5 @@ +databaseChangeLog: + - include: + file: db/changelog/001-create-table.yaml + - include: + file: db/changelog/002-fill-table.yaml \ No newline at end of file From 6ffee73c44f31c47b7b8089b6868f1f66b388264 Mon Sep 17 00:00:00 2001 From: Mihail Yuzhakov Date: Tue, 2 Sep 2025 17:44:13 +0300 Subject: [PATCH 4/7] =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=20=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3?= =?UTF-8?q?,=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=20=D1=81=20?= =?UTF-8?q?=D0=BA=D1=8D=D1=88=D0=B5=D0=BC,=20=D0=BD=D0=B0=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B0=D0=BD=20ApiResponse=20=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=20ResponseEntuty,=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B2=D1=81=D0=B5=D1=85=20dto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 45 ++++++++- .../springtodo/configuration/RedisConfig.java | 68 ++++++++------ .../controllers/ControllerAdvice.java | 11 ++- .../controllers/GlobalExceptionHandler.java | 92 +++++++++++++++++++ .../controllers/TaskController.java | 41 +++++---- .../emobile/springtodo/dto/ApiResponse.java | 28 ++++++ .../springtodo/dto/TaskCreateRequest.java | 1 + .../com/emobile/springtodo/dto/TaskDTO.java | 14 --- .../emobile/springtodo/dto/TaskResponse.java | 14 +++ .../repositories/JdbcTemplateTaskDaoImpl.java | 41 +++++++-- .../springtodo/repositories/TaskDao.java | 4 +- .../springtodo/services/MetricsService.java | 30 ++---- .../services/MetricsServiceInterface.java | 5 +- .../springtodo/services/TaskService.java | 92 ++++++++++++------- .../services/TaskServiceInterface.java | 19 ++-- .../emobile/springtodo/utils/TaskMapper.java | 6 +- src/main/resources/application.yaml | 9 +- .../db/changelog/001-create-table.yaml | 4 +- .../db/changelog/002-fill-table.yaml | 8 +- 19 files changed, 385 insertions(+), 147 deletions(-) create mode 100644 src/main/java/com/emobile/springtodo/controllers/GlobalExceptionHandler.java create mode 100644 src/main/java/com/emobile/springtodo/dto/ApiResponse.java delete mode 100644 src/main/java/com/emobile/springtodo/dto/TaskDTO.java diff --git a/pom.xml b/pom.xml index 954b3e2e..8892f1fa 100644 --- a/pom.xml +++ b/pom.xml @@ -15,7 +15,7 @@ SpringToDo - 21 + 17 @@ -39,15 +39,31 @@ org.springframework.boot spring-boot-starter-data-redis + + io.micrometer + micrometer-registry-prometheus + + + org.springframework.boot + spring-boot-starter-actuator + org.postgresql postgresql runtime + + org.springframework.boot + spring-boot-starter-validation + org.projectlombok lombok + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + org.springframework.boot spring-boot-starter-test @@ -80,6 +96,33 @@ org.springframework.boot spring-boot-maven-plugin + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + + + org.mapstruct + mapstruct-processor + 1.5.5.Final + + + + org.projectlombok + lombok + 1.18.32 + + + org.projectlombok + lombok-mapstruct-binding + 0.2.0 + + + + diff --git a/src/main/java/com/emobile/springtodo/configuration/RedisConfig.java b/src/main/java/com/emobile/springtodo/configuration/RedisConfig.java index cdf0faca..5f2f2f4e 100644 --- a/src/main/java/com/emobile/springtodo/configuration/RedisConfig.java +++ b/src/main/java/com/emobile/springtodo/configuration/RedisConfig.java @@ -1,6 +1,9 @@ package com.emobile.springtodo.configuration; -import org.springframework.beans.factory.annotation.Value; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; @@ -8,8 +11,6 @@ import org.springframework.data.redis.cache.RedisCacheConfiguration; import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.connection.RedisStandaloneConfiguration; -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializationContext; @@ -21,37 +22,50 @@ @EnableCaching public class RedisConfig { - @Value("${spring.redis.host}") - private String redisHost; - - @Value("${spring.redis.port}") - private int redisPort; - @Bean - public RedisConnectionFactory redisConnectionFactory() { - RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisHost, redisPort); - return new LettuceConnectionFactory(config); - } + public GenericJackson2JsonRedisSerializer redisJsonSerializer() { + ObjectMapper om = JsonMapper.builder() + .findAndAddModules() // JavaTimeModule и др. + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .build(); - @Bean - public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { - RedisTemplate template = new RedisTemplate<>(); - template.setConnectionFactory(connectionFactory); - template.setKeySerializer(new StringRedisSerializer()); - template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); - return template; + // Важно: включаем type info, чтобы при чтении восстанавливался TaskResponse, а не LinkedHashMap + om.activateDefaultTyping( + BasicPolymorphicTypeValidator.builder() + .allowIfSubType("com.emobile.springtodo") // ограничь своими пакетами + .allowIfSubType("java.util") + .build(), + ObjectMapper.DefaultTyping.NON_FINAL + ); + + return new GenericJackson2JsonRedisSerializer(om); } @Bean - public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { - RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() - .entryTtl(Duration.ofHours(1)) - .disableCachingNullValues() + public CacheManager cacheManager(RedisConnectionFactory cf, + GenericJackson2JsonRedisSerializer jsonSer) { + RedisCacheConfiguration base = RedisCacheConfiguration.defaultCacheConfig() .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) - .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonSer)) + .disableCachingNullValues() + .computePrefixWith(name -> "todo:" + name + "::") + .entryTtl(Duration.ofMinutes(30)); - return RedisCacheManager.builder(connectionFactory) - .cacheDefaults(config) + return RedisCacheManager.builder(cf) + .cacheDefaults(base) + .transactionAware() .build(); } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory cf, + GenericJackson2JsonRedisSerializer jsonSer) { + RedisTemplate tpl = new RedisTemplate<>(); + tpl.setConnectionFactory(cf); + tpl.setKeySerializer(new StringRedisSerializer()); + tpl.setValueSerializer(jsonSer); + tpl.setHashKeySerializer(new StringRedisSerializer()); + tpl.setHashValueSerializer(jsonSer); + return tpl; + } } diff --git a/src/main/java/com/emobile/springtodo/controllers/ControllerAdvice.java b/src/main/java/com/emobile/springtodo/controllers/ControllerAdvice.java index 0a8f8b6c..81a1d251 100644 --- a/src/main/java/com/emobile/springtodo/controllers/ControllerAdvice.java +++ b/src/main/java/com/emobile/springtodo/controllers/ControllerAdvice.java @@ -1,16 +1,17 @@ package com.emobile.springtodo.controllers; +import com.emobile.springtodo.dto.ApiResponse; import com.emobile.springtodo.exceptions.TaskNotFoundException; -import org.springframework.http.ResponseEntity; +import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ExceptionHandler; import com.emobile.springtodo.dto.ErrorResponse; +import org.springframework.web.bind.annotation.ResponseStatus; @org.springframework.web.bind.annotation.ControllerAdvice public class ControllerAdvice { + @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(TaskNotFoundException.class) - public ResponseEntity TaskNotFoundExceptionHandler(TaskNotFoundException exception) { - return ResponseEntity - .badRequest() - .body(new ErrorResponse("ACCESS_ERROR", exception.getMessage())); + public ApiResponse TaskNotFoundExceptionHandler(TaskNotFoundException exception) { + return ApiResponse.error(new ErrorResponse("ACCESS_ERROR", exception.getMessage())); } } diff --git a/src/main/java/com/emobile/springtodo/controllers/GlobalExceptionHandler.java b/src/main/java/com/emobile/springtodo/controllers/GlobalExceptionHandler.java new file mode 100644 index 00000000..1c12197e --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controllers/GlobalExceptionHandler.java @@ -0,0 +1,92 @@ +package com.emobile.springtodo.controllers; + +import com.emobile.springtodo.dto.ApiResponse; +import com.emobile.springtodo.dto.ErrorResponse; +import jakarta.validation.ConstraintViolationException; +import org.springframework.http.*; +import org.springframework.validation.BindException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RestControllerAdvice +@ResponseStatus(HttpStatus.BAD_REQUEST) +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + protected ApiResponse handleMethodArgumentNotValid( + MethodArgumentNotValidException ex) { + + List> errors = ex.getBindingResult().getFieldErrors().stream() + .map(fe -> { + Map m = new LinkedHashMap<>(); + m.put("field", fe.getField()); + m.put("message", fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid value"); + Object rejected = fe.getRejectedValue(); + if (rejected != null) { + m.put("rejectedValue", rejected); + } + return m; + }) + .toList(); + + ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); + pd.setTitle("Validation failed"); + pd.setDetail("Invalid request body"); + pd.setProperty("errors", errors); + return ApiResponse.error(new ErrorResponse("VALIDATION_ERROR", pd.toString())); + } + + @ExceptionHandler(ConstraintViolationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleConstraintViolation(ConstraintViolationException ex) { + List> errors = ex.getConstraintViolations().stream() + .map(cv -> { + Map m = new LinkedHashMap<>(); + m.put("field", cv.getPropertyPath().toString()); + m.put("message", cv.getMessage() != null ? cv.getMessage() : "Invalid value"); + Object invalid = cv.getInvalidValue(); + if (invalid != null) { + m.put("rejectedValue", invalid); + } + return m; + }) + .toList(); + + ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); + pd.setTitle("Validation failed"); + pd.setDetail("Invalid request parameters"); + pd.setProperty("errors", errors); + return ApiResponse.error(new ErrorResponse("VALIDATION_ERROR", pd.toString())); + } + + @ExceptionHandler(BindException.class) + protected ApiResponse handleBindException(BindException ex) { + + List> errors = ex.getBindingResult().getFieldErrors().stream() + .map(fe -> { + Map m = new LinkedHashMap<>(); + m.put("field", fe.getField()); + m.put("message", fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid value"); + Object rejected = fe.getRejectedValue(); + if (rejected != null) { + m.put("rejectedValue", rejected); + } + return m; + }) + .toList(); + + ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); + pd.setTitle("Validation failed"); + pd.setDetail("Binding error"); + pd.setProperty("errors", errors); + return ApiResponse.ok(new ErrorResponse("VALIDATION_ERROR", pd.toString())); + } +} diff --git a/src/main/java/com/emobile/springtodo/controllers/TaskController.java b/src/main/java/com/emobile/springtodo/controllers/TaskController.java index a064a0ad..75a6a609 100644 --- a/src/main/java/com/emobile/springtodo/controllers/TaskController.java +++ b/src/main/java/com/emobile/springtodo/controllers/TaskController.java @@ -1,47 +1,54 @@ package com.emobile.springtodo.controllers; -import com.emobile.springtodo.dto.TaskDTO; -import com.emobile.springtodo.model.Task; +import com.emobile.springtodo.dto.ApiResponse; +import com.emobile.springtodo.dto.TaskCreateRequest; +import com.emobile.springtodo.dto.TaskResponse; +import com.emobile.springtodo.dto.TaskUpdateRequest; import com.emobile.springtodo.services.TaskServiceInterface; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController +@Validated +@RequiredArgsConstructor @RequestMapping("/api/tasks") public class TaskController { private final TaskServiceInterface service; - public TaskController(TaskServiceInterface service) { - this.service = service; - } - @GetMapping - public List getAllTasks() { - return service.getAllTasks(); + public ApiResponse> getAllTasks() { + return ApiResponse.ok(service.getAllTasks()); } @GetMapping("/{id}") - public ResponseEntity getTaskById(@PathVariable Long id) { - return ResponseEntity.ok(service.get(id)); + public ApiResponse getTaskById(@Positive @PathVariable Long id) { + return ApiResponse.ok(service.get(id)); } @PostMapping - public Task createTask(@RequestBody TaskDTO taskDTO) { - return service.create(taskDTO); + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createTask(@Valid @RequestBody TaskCreateRequest taskCreateRequest) { + TaskResponse taskResponse = service.create(taskCreateRequest); + return ApiResponse.created(taskResponse); } @PutMapping("/{id}") - public ResponseEntity updateTask(@PathVariable Long id, @RequestBody TaskDTO taskDetails) { - Task updatedTask = service.updateTask(id, taskDetails); - return ResponseEntity.ok(updatedTask); + public ApiResponse updateTask(@Positive @PathVariable Long id, + @Valid @RequestBody TaskUpdateRequest taskUpdateRequest) { + return ApiResponse.ok(service.updateTask(id, taskUpdateRequest)); } @DeleteMapping("/{id}") - public ResponseEntity deleteTask(@PathVariable Long id) { + public ApiResponse deleteTask(@Positive @PathVariable Long id) { service.deleteTask(id); - return ResponseEntity.ok("Task delete successfully"); + return ApiResponse.ok("Task delete successfully"); } } diff --git a/src/main/java/com/emobile/springtodo/dto/ApiResponse.java b/src/main/java/com/emobile/springtodo/dto/ApiResponse.java new file mode 100644 index 00000000..4d09cd54 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/ApiResponse.java @@ -0,0 +1,28 @@ +package com.emobile.springtodo.dto; + +import java.time.Instant; +import java.util.Map; + +public record ApiResponse( + boolean success, + T data, + ErrorResponse error, + Map meta, + Instant timestamp +) { + public static ApiResponse ok(T data) { + return new ApiResponse<>(true, data, null, null, Instant.now()); + } + public static ApiResponse created(T data) { + return new ApiResponse<>(true, data, null, null, Instant.now()); + } + public static ApiResponse noContent() { + return new ApiResponse<>(true, null, null, null, Instant.now()); + } + public static ApiResponse error(ErrorResponse error) { + return new ApiResponse<>(false, null, error, null, Instant.now()); + } + public ApiResponse withMeta(Map meta) { + return new ApiResponse<>(this.success, this.data, this.error, meta, this.timestamp); + } +} diff --git a/src/main/java/com/emobile/springtodo/dto/TaskCreateRequest.java b/src/main/java/com/emobile/springtodo/dto/TaskCreateRequest.java index 2ce42a40..24fab02c 100644 --- a/src/main/java/com/emobile/springtodo/dto/TaskCreateRequest.java +++ b/src/main/java/com/emobile/springtodo/dto/TaskCreateRequest.java @@ -11,6 +11,7 @@ public class TaskCreateRequest { @Size(min = 1, max = 255, message = "Title must be between 1 and 255 characters") private String title; + @NotBlank(message = "Description is required") @Size(max = 2000, message = "Description must not exceed 2000 characters") private String description; } \ No newline at end of file diff --git a/src/main/java/com/emobile/springtodo/dto/TaskDTO.java b/src/main/java/com/emobile/springtodo/dto/TaskDTO.java deleted file mode 100644 index dde3fc3c..00000000 --- a/src/main/java/com/emobile/springtodo/dto/TaskDTO.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.emobile.springtodo.dto; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.Setter; - -@Setter -@Getter -@AllArgsConstructor -public class TaskDTO { - private String title; - private String description; - private boolean isCompleted; -} diff --git a/src/main/java/com/emobile/springtodo/dto/TaskResponse.java b/src/main/java/com/emobile/springtodo/dto/TaskResponse.java index 3a7642d7..27f8bd35 100644 --- a/src/main/java/com/emobile/springtodo/dto/TaskResponse.java +++ b/src/main/java/com/emobile/springtodo/dto/TaskResponse.java @@ -1,5 +1,7 @@ package com.emobile.springtodo.dto; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -12,10 +14,22 @@ @NoArgsConstructor @AllArgsConstructor public class TaskResponse { + @Positive(message = "id must be positive") + @NotNull private Long id; + + @NotNull private String title; + @NotNull + private String description; + + @NotNull private Boolean completed; + + @NotNull private LocalDateTime createdAt; + + @NotNull private LocalDateTime updatedAt; } diff --git a/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java b/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java index 39df125a..f5b91831 100644 --- a/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java +++ b/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java @@ -1,5 +1,6 @@ package com.emobile.springtodo.repositories; +import com.emobile.springtodo.exceptions.TaskNotFoundException; import com.emobile.springtodo.model.Task; import lombok.RequiredArgsConstructor; import org.springframework.dao.EmptyResultDataAccessException; @@ -38,9 +39,21 @@ public Optional findById(Long id) { } @Override - public void save(Task task) { - String SQL = "INSERT INTO tasks (title, description, completed, created_at, updated_at) VALUES (?, ?, ?, ?, ?)"; - jdbcTemplate.update(SQL, task.getTitle(), task.getDescription(), task.isCompleted(), task.getCreated_at(), task.getUpdated_at()); + public Task save(Task task) { + String sql = """ + INSERT INTO tasks (title, description, completed, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) RETURNING id + """; + Long id = jdbcTemplate.queryForObject( + sql, Long.class, + task.getTitle(), + task.getDescription(), + task.isCompleted(), + java.sql.Timestamp.valueOf(task.getCreated_at()), + java.sql.Timestamp.valueOf(task.getUpdated_at()) + ); + task.setId(id); + return task; } @Override @@ -50,8 +63,24 @@ public void delete(Task task) { } @Override - public void update(Long id, Task task) { - String SQL = "UPDATE tasks SET title = ?, description = ?, completed = ?, created_at = ?, updated_at = ? WHERE id = ?"; - jdbcTemplate.update(SQL, task.getTitle(), task.getDescription(), task.isCompleted(), task.getCreated_at(), task.getUpdated_at(), task.getId()); + public Task update(Long id, Task task) { + String sql = "UPDATE tasks " + + "SET title = ?, description = ?, completed = ?, updated_at = ? " + + "WHERE id = ?"; + int updated = jdbcTemplate.update( + sql, + task.getTitle(), + task.getDescription(), + task.isCompleted(), + java.sql.Timestamp.valueOf(task.getUpdated_at()), + id + ); + + if (updated == 0) { + throw new TaskNotFoundException("Task not found with id " + id); + } + + task.setId(id); + return task; } } \ No newline at end of file diff --git a/src/main/java/com/emobile/springtodo/repositories/TaskDao.java b/src/main/java/com/emobile/springtodo/repositories/TaskDao.java index 9f50007d..49f0ccdc 100644 --- a/src/main/java/com/emobile/springtodo/repositories/TaskDao.java +++ b/src/main/java/com/emobile/springtodo/repositories/TaskDao.java @@ -9,7 +9,7 @@ public interface TaskDao { Optional> findAll(); Optional findById(Long id); - void save(Task task); + Task save(Task task); void delete(Task task); - void update(Long id, Task task); + Task update(Long id, Task task); } diff --git a/src/main/java/com/emobile/springtodo/services/MetricsService.java b/src/main/java/com/emobile/springtodo/services/MetricsService.java index 075d5a98..09c5bfc9 100644 --- a/src/main/java/com/emobile/springtodo/services/MetricsService.java +++ b/src/main/java/com/emobile/springtodo/services/MetricsService.java @@ -5,12 +5,15 @@ import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; import java.util.HashMap; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; +@Service @RequiredArgsConstructor @Slf4j public class MetricsService implements MetricsServiceInterface { @@ -88,7 +91,7 @@ public void recordTaskMetrics(Task task) { recordTitleLength(task.getTitle().length()); } - if (Boolean.TRUE.equals(task.getCompleted())) { + if (task.isCompleted()) { incrementCompletedTasks(); } } @@ -135,12 +138,6 @@ public void incrementDeletedTasks() { log.debug("Deleted tasks counter incremented"); } - @Override - public void recordTaskCompletionTime(long milliseconds) { - taskCompletionTimer.record(milliseconds, TimeUnit.MILLISECONDS); - log.debug("Task completion time recorded: {} ms", milliseconds); - } - @Override public void recordTaskCreationTime(long milliseconds) { taskCreationTimer.record(milliseconds, TimeUnit.MILLISECONDS); @@ -148,21 +145,8 @@ public void recordTaskCreationTime(long milliseconds) { } @Override - public Map getMetricsSnapshot() { - Map snapshot = new HashMap<>(); - - snapshot.put("createdTasks", createdTasksCounter.count()); - snapshot.put("completedTasks", completedTasksCounter.count()); - snapshot.put("deletedTasks", deletedTasksCounter.count()); - snapshot.put("activeTasks", activeTasksCount.get()); - snapshot.put("totalCompleted", completedTasksTotal.get()); - - // Добавляем перцентили из таймеров - snapshot.put("creationTimeP50", taskCreationTimer.percentile(0.5, TimeUnit.MILLISECONDS)); - snapshot.put("creationTimeP95", taskCreationTimer.percentile(0.95, TimeUnit.MILLISECONDS)); - snapshot.put("completionTimeP50", taskCompletionTimer.percentile(0.5, TimeUnit.MILLISECONDS)); - snapshot.put("completionTimeP95", taskCompletionTimer.percentile(0.95, TimeUnit.MILLISECONDS)); - - return snapshot; + public void recordTaskUpdateTime(long milliseconds) { + taskCreationTimer.record(milliseconds, TimeUnit.MILLISECONDS); + log.debug("Task update time recorded: {} ms", milliseconds); } } diff --git a/src/main/java/com/emobile/springtodo/services/MetricsServiceInterface.java b/src/main/java/com/emobile/springtodo/services/MetricsServiceInterface.java index 0e012fe4..47f7d1e0 100644 --- a/src/main/java/com/emobile/springtodo/services/MetricsServiceInterface.java +++ b/src/main/java/com/emobile/springtodo/services/MetricsServiceInterface.java @@ -1,12 +1,9 @@ package com.emobile.springtodo.services; -import java.util.Map; - public interface MetricsServiceInterface { void incrementCreatedTasks(); void incrementCompletedTasks(); void incrementDeletedTasks(); - void recordTaskCompletionTime(long milliseconds); void recordTaskCreationTime(long milliseconds); - Map getMetricsSnapshot(); + void recordTaskUpdateTime(long milliseconds); } \ No newline at end of file diff --git a/src/main/java/com/emobile/springtodo/services/TaskService.java b/src/main/java/com/emobile/springtodo/services/TaskService.java index d1f03a49..efe1dc9c 100644 --- a/src/main/java/com/emobile/springtodo/services/TaskService.java +++ b/src/main/java/com/emobile/springtodo/services/TaskService.java @@ -1,18 +1,23 @@ package com.emobile.springtodo.services; import com.emobile.springtodo.dto.TaskCreateRequest; -import com.emobile.springtodo.dto.TaskDTO; import com.emobile.springtodo.dto.TaskResponse; +import com.emobile.springtodo.dto.TaskUpdateRequest; import com.emobile.springtodo.exceptions.TaskNotFoundException; import com.emobile.springtodo.model.Task; import com.emobile.springtodo.repositories.TaskDao; import com.emobile.springtodo.utils.TaskMapper; 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.cache.annotation.Caching; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; @Service @@ -24,61 +29,86 @@ public class TaskService implements TaskServiceInterface { private final MetricsService metricsService; @Override - public List getAllTasks() { - return taskRepository.findAll() + @Cacheable(value = "tasks", key = "'all'", sync = true) + public List getAllTasks() { + List tasks = taskRepository.findAll() .orElseThrow(() -> new TaskNotFoundException("Tasks not found")); + return tasks.stream().map(taskMapper::toResponse).toList(); } @Override - public Task get(Long taskId) { - return taskRepository.findById(taskId) + @Cacheable(value = "task", key = "#taskId", sync = true) + public TaskResponse get(Long taskId) { + Task task = taskRepository.findById(taskId) .orElseThrow(() -> new TaskNotFoundException("Task not found with id " + taskId)); + return taskMapper.toResponse(task); } + @Transactional + @Caching( + put = @CachePut(value = "task", key = "#result.id"), + evict = { + @CacheEvict(value = "tasks", allEntries = true), + @CacheEvict(value = "tasks_completed", allEntries = true), + @CacheEvict(value = "tasks_active", allEntries = true) + } + ) @Override public TaskResponse create(TaskCreateRequest taskCreateRequest) { - long startTime = System.currentTimeMillis(); - + long start = System.currentTimeMillis(); try { - log.info("Creating new todo: {}", request.getTitle()); + log.info("Creating new todo: {}", taskCreateRequest.getTitle()); Task task = taskMapper.toEntity(taskCreateRequest); - taskRepository.save(task); + Task newTask = taskRepository.save(task); metricsService.incrementCreatedTasks(); metricsService.recordTaskMetrics(task); - return taskMapper.toResponse(task); + return taskMapper.toResponse(newTask); } finally { - long duration = System.currentTimeMillis() - startTime; - metricsService.recordTaskCreationTime(duration); + metricsService.recordTaskCreationTime(System.currentTimeMillis() - start); } - - - Task task = new Task(); - task.setDescription(taskDTO.getDescription()); - task.setTitle(taskDTO.getTitle()); - task.setCompleted(taskDTO.isCompleted()); - task.setCreated_at(LocalDateTime.now()); - task.setUpdated_at(LocalDateTime.now()); - taskRepository.save(task); - return task; } + @Transactional + @Caching( + put = @CachePut(value = "task", key = "#id"), + evict = { + @CacheEvict(value = "tasks", allEntries = true), + @CacheEvict(value = "tasks_completed", allEntries = true), + @CacheEvict(value = "tasks_active", allEntries = true) + } + ) @Override - public Task updateTask(Long id, TaskDTO taskDetails) { - Task task = taskRepository.findById(id) - .orElseThrow(() -> new TaskNotFoundException("Task not found with id " + id)); - task.setTitle(taskDetails.getTitle()); - task.setDescription(taskDetails.getDescription()); - task.setCompleted(taskDetails.isCompleted()); - taskRepository.update(id, task); - return task; + public TaskResponse updateTask(Long id, TaskUpdateRequest taskDetails) { + long start = System.currentTimeMillis(); + try { + log.info("Updating task with id: {}", id); + Task task = taskRepository.findById(id) + .orElseThrow(() -> new TaskNotFoundException("Task not found with id " + id)); + + taskMapper.updateEntityFromRequest(taskDetails, task); + Task updatedTask = taskRepository.update(id, task); + + log.info("Task updated successfully: {}", id); + return taskMapper.toResponse(updatedTask); + } finally { + metricsService.recordTaskUpdateTime(System.currentTimeMillis() - start); + } } + @Transactional + @Caching(evict = { + @CacheEvict(value = "task", key = "#id"), + @CacheEvict(value = "tasks", allEntries = true), + @CacheEvict(value = "tasks_completed", allEntries = true), + @CacheEvict(value = "tasks_active", allEntries = true) + }) @Override public void deleteTask(Long id) { Task task = taskRepository.findById(id) .orElseThrow(() -> new TaskNotFoundException("Task not found with id " + id)); taskRepository.delete(task); + metricsService.incrementDeletedTasks(); } } diff --git a/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java b/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java index cf10d73c..8f2d1721 100644 --- a/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java +++ b/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java @@ -1,20 +1,27 @@ package com.emobile.springtodo.services; import com.emobile.springtodo.dto.TaskCreateRequest; -import com.emobile.springtodo.dto.TaskDTO; import com.emobile.springtodo.dto.TaskResponse; -import com.emobile.springtodo.model.Task; +import com.emobile.springtodo.dto.TaskUpdateRequest; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Caching; import java.util.List; public interface TaskServiceInterface { - public List getAllTasks(); - public Task get(Long taskId); - public Task create(TaskDTO taskDTO); + public List getAllTasks(); + public TaskResponse get(Long taskId); TaskResponse create(TaskCreateRequest taskCreateRequest); - public Task updateTask(Long id, TaskDTO taskDetails); + @Caching(evict = { + @CacheEvict(value = "task", key = "#id"), + @CacheEvict(value = "tasks", allEntries = true), + @CacheEvict(value = "tasks_completed", allEntries = true), + @CacheEvict(value = "tasks_active", allEntries = true) + }) + TaskResponse updateTask(Long id, TaskUpdateRequest taskDetails); + public void deleteTask(Long id); } diff --git a/src/main/java/com/emobile/springtodo/utils/TaskMapper.java b/src/main/java/com/emobile/springtodo/utils/TaskMapper.java index 4a28ac28..64a8609f 100644 --- a/src/main/java/com/emobile/springtodo/utils/TaskMapper.java +++ b/src/main/java/com/emobile/springtodo/utils/TaskMapper.java @@ -3,15 +3,14 @@ import com.emobile.springtodo.dto.TaskResponse; import com.emobile.springtodo.dto.TaskUpdateRequest; import com.emobile.springtodo.model.Task; -import org.mapstruct.Mapping; -import org.mapstruct.MappingTarget; +import org.mapstruct.*; import org.mapstruct.factory.Mappers; import org.springframework.data.domain.Page; import java.util.List; +@Mapper(componentModel = "spring") public interface TaskMapper { - TaskMapper INSTANCE = Mappers.getMapper(TaskMapper.class); // Маппинг из Entity в ResponseDTO @Mapping(source = "created_at", target = "createdAt") @@ -28,6 +27,7 @@ public interface TaskMapper { Task toEntity(TaskCreateRequest request); // Маппинг из UpdateRequest DTO в Entity (обновление существующей сущности) + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) @Mapping(target = "id", ignore = true) @Mapping(target = "created_at", ignore = true) @Mapping(target = "updated_at", expression = "java(java.time.LocalDateTime.now())") diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 9ad37496..b987edff 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -12,7 +12,12 @@ spring: application: name: SpringToDo datasource: - url: jdbc:postgresql://localhost:5432/taskmanager + url: jdbc:postgresql://localhost:5432/postgres username: postgres - password: 10031998 + password: postgres driver-class-name: org.postgresql.Driver +management: + endpoints: + web: + exposure: + include: health,metrics,info \ No newline at end of file diff --git a/src/main/resources/db/changelog/001-create-table.yaml b/src/main/resources/db/changelog/001-create-table.yaml index 9c200c04..9f234cc6 100644 --- a/src/main/resources/db/changelog/001-create-table.yaml +++ b/src/main/resources/db/changelog/001-create-table.yaml @@ -18,13 +18,13 @@ databaseChangeLog: type: VARCHAR(255) constraints: nullable: false - unique: true + unique: false - column: name: description type: VARCHAR(2000) constraints: nullable: false - unique: true + unique: false - column: name: completed type: BOOLEAN diff --git a/src/main/resources/db/changelog/002-fill-table.yaml b/src/main/resources/db/changelog/002-fill-table.yaml index 0d3b48dc..db1c1e86 100644 --- a/src/main/resources/db/changelog/002-fill-table.yaml +++ b/src/main/resources/db/changelog/002-fill-table.yaml @@ -8,7 +8,7 @@ databaseChangeLog: columns: - column: name: id - value: "1" + autoIncrement: "true" - column: name: title value: "Новая задача № 1" @@ -29,7 +29,7 @@ databaseChangeLog: columns: - column: name: id - value: "2" + autoIncrement: "true" - column: name: title value: "Новая задача № 2" @@ -50,7 +50,7 @@ databaseChangeLog: columns: - column: name: id - value: "3" + autoIncrement: "true" - column: name: title value: "Новая задача № 3" @@ -71,7 +71,7 @@ databaseChangeLog: columns: - column: name: id - value: "4" + autoIncrement: "true" - column: name: title value: "Новая задача № 4" From b24c3928233b21bb5119787f23426e2c1f4e275a Mon Sep 17 00:00:00 2001 From: Mihail Yuzhakov Date: Wed, 10 Sep 2025 17:14:20 +0300 Subject: [PATCH 5/7] =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=BF=D0=B0=D0=B3=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F,=20=D0=BD=D0=BE=20=D1=81=20=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=B1=D0=BA=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../springtodo/controllers/TaskController.java | 11 +++++++++-- .../repositories/JdbcTemplateTaskDaoImpl.java | 11 +++++++---- .../springtodo/repositories/TaskDao.java | 2 +- .../springtodo/services/TaskService.java | 17 +++++++++++++---- .../services/TaskServiceInterface.java | 4 ++-- 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/emobile/springtodo/controllers/TaskController.java b/src/main/java/com/emobile/springtodo/controllers/TaskController.java index 75a6a609..90f91449 100644 --- a/src/main/java/com/emobile/springtodo/controllers/TaskController.java +++ b/src/main/java/com/emobile/springtodo/controllers/TaskController.java @@ -7,7 +7,9 @@ import com.emobile.springtodo.services.TaskServiceInterface; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; @@ -16,6 +18,7 @@ import java.util.List; +@Slf4j @RestController @Validated @RequiredArgsConstructor @@ -24,8 +27,12 @@ public class TaskController { private final TaskServiceInterface service; @GetMapping - public ApiResponse> getAllTasks() { - return ApiResponse.ok(service.getAllTasks()); + public ApiResponse> getAllTasks( + @RequestParam(defaultValue = "10") @Positive int limit, + @RequestParam(defaultValue = "0") @PositiveOrZero int offset) { + log.warn("getAllTask() controller"); + List taskResponses = service.getAllTasks(limit, offset); + return ApiResponse.ok(taskResponses); } @GetMapping("/{id}") diff --git a/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java b/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java index f5b91831..bd7ff6c9 100644 --- a/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java +++ b/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java @@ -3,6 +3,7 @@ import com.emobile.springtodo.exceptions.TaskNotFoundException; import com.emobile.springtodo.model.Task; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; @@ -10,6 +11,7 @@ import java.util.List; import java.util.Optional; +@Slf4j @Repository @RequiredArgsConstructor public class JdbcTemplateTaskDaoImpl implements TaskDao { @@ -17,11 +19,12 @@ public class JdbcTemplateTaskDaoImpl implements TaskDao { private final JdbcTemplate jdbcTemplate; @Override - public Optional> findAll() { - String SQL = "SELECT * FROM tasks"; + public Optional> findAllWithPagination(int limit, int offset) { + String SQL = "SELECT * FROM tasks ORDER BY id LIMIT ? OFFSET ?"; try { - List task = jdbcTemplate.query(SQL, new RowMapperImpl()); - return Optional.of(task); + List tasks = jdbcTemplate.query(SQL, new RowMapperImpl(), limit, offset); + log.info("repo task = {}", tasks); + return Optional.of(tasks); } catch (EmptyResultDataAccessException e) { return Optional.empty(); } diff --git a/src/main/java/com/emobile/springtodo/repositories/TaskDao.java b/src/main/java/com/emobile/springtodo/repositories/TaskDao.java index 49f0ccdc..9b627f76 100644 --- a/src/main/java/com/emobile/springtodo/repositories/TaskDao.java +++ b/src/main/java/com/emobile/springtodo/repositories/TaskDao.java @@ -7,7 +7,7 @@ public interface TaskDao { - Optional> findAll(); + Optional> findAllWithPagination(int limit, int offset); Optional findById(Long id); Task save(Task task); void delete(Task task); diff --git a/src/main/java/com/emobile/springtodo/services/TaskService.java b/src/main/java/com/emobile/springtodo/services/TaskService.java index efe1dc9c..fd6bc05c 100644 --- a/src/main/java/com/emobile/springtodo/services/TaskService.java +++ b/src/main/java/com/emobile/springtodo/services/TaskService.java @@ -13,12 +13,16 @@ import org.springframework.cache.annotation.CachePut; import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.Caching; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; @Service @Slf4j @@ -30,10 +34,15 @@ public class TaskService implements TaskServiceInterface { @Override @Cacheable(value = "tasks", key = "'all'", sync = true) - public List getAllTasks() { - List tasks = taskRepository.findAll() - .orElseThrow(() -> new TaskNotFoundException("Tasks not found")); - return tasks.stream().map(taskMapper::toResponse).toList(); + public List getAllTasks(int limit, int offset) { + log.info("log getAllTasks()"); + Optional> tasksOptional = taskRepository.findAllWithPagination(limit, offset); + List tasksk = tasksOptional.orElse(List.of()); + log.info("service = {}", tasksk); + return tasksOptional.orElse(List.of()) + .stream() + .map(taskMapper::toResponse) + .collect(Collectors.toList()); } @Override diff --git a/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java b/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java index 8f2d1721..89cccadf 100644 --- a/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java +++ b/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java @@ -9,8 +9,8 @@ import java.util.List; public interface TaskServiceInterface { - public List getAllTasks(); - public TaskResponse get(Long taskId); + List getAllTasks(int limit, int offset); + TaskResponse get(Long taskId); TaskResponse create(TaskCreateRequest taskCreateRequest); From f1fbd26508032ddf960f524ff1b0f307cc7a6c2e Mon Sep 17 00:00:00 2001 From: Mihail Yuzhakov Date: Tue, 14 Oct 2025 13:47:14 +0300 Subject: [PATCH 6/7] tests + pagination --- pom.xml | 20 +++ .../springtodo/dto/TaskCreateRequest.java | 2 + .../emobile/springtodo/dto/TaskResponse.java | 7 + .../com/emobile/springtodo/model/Task.java | 7 + .../repositories/JdbcTemplateTaskDaoImpl.java | 12 ++ .../springtodo/repositories/TaskDao.java | 1 + .../springtodo/services/MetricsService.java | 121 +----------------- .../services/MetricsServiceInterface.java | 9 -- .../springtodo/services/TaskService.java | 47 +++---- .../services/TaskServiceInterface.java | 4 +- src/main/resources/application.yaml | 12 +- .../emobile/springtodo/DatabaseTestBase.java | 27 ++++ .../springtodo/IntegrationTestBase.java | 8 ++ .../controller/TaskControllerTest.java | 107 ++++++++++++++++ .../emobile/springtodo/dao/TaskDaoTest.java | 55 ++++++++ .../springtodo/service/TaskServiceTest.java | 96 ++++++++++++++ src/test/resources/sql/add-tasks.sql | 11 ++ 17 files changed, 386 insertions(+), 160 deletions(-) delete mode 100644 src/main/java/com/emobile/springtodo/services/MetricsServiceInterface.java create mode 100644 src/test/java/com/emobile/springtodo/DatabaseTestBase.java create mode 100644 src/test/java/com/emobile/springtodo/IntegrationTestBase.java create mode 100644 src/test/java/com/emobile/springtodo/controller/TaskControllerTest.java create mode 100644 src/test/java/com/emobile/springtodo/dao/TaskDaoTest.java create mode 100644 src/test/java/com/emobile/springtodo/service/TaskServiceTest.java create mode 100644 src/test/resources/sql/add-tasks.sql diff --git a/pom.xml b/pom.xml index 8892f1fa..c8b0b139 100644 --- a/pom.xml +++ b/pom.xml @@ -23,6 +23,26 @@ org.springframework.boot spring-boot-starter + + org.testcontainers + testcontainers + test + + + org.skyscreamer + jsonassert + test + + + org.testcontainers + postgresql + test + + + org.testcontainers + junit-jupiter + test + org.springframework.boot spring-boot-starter-data-jdbc diff --git a/src/main/java/com/emobile/springtodo/dto/TaskCreateRequest.java b/src/main/java/com/emobile/springtodo/dto/TaskCreateRequest.java index 24fab02c..776825f9 100644 --- a/src/main/java/com/emobile/springtodo/dto/TaskCreateRequest.java +++ b/src/main/java/com/emobile/springtodo/dto/TaskCreateRequest.java @@ -1,10 +1,12 @@ package com.emobile.springtodo.dto; import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; import lombok.Data; import jakarta.validation.constraints.NotBlank; @Data +@AllArgsConstructor public class TaskCreateRequest { @NotBlank(message = "Title is required") diff --git a/src/main/java/com/emobile/springtodo/dto/TaskResponse.java b/src/main/java/com/emobile/springtodo/dto/TaskResponse.java index 27f8bd35..28c28a1e 100644 --- a/src/main/java/com/emobile/springtodo/dto/TaskResponse.java +++ b/src/main/java/com/emobile/springtodo/dto/TaskResponse.java @@ -32,4 +32,11 @@ public class TaskResponse { @NotNull private LocalDateTime updatedAt; + + public TaskResponse(Long id, String title, String description, Boolean completed) { + this.id = id; + this.title = title; + this.description = description; + this.completed = completed; + } } diff --git a/src/main/java/com/emobile/springtodo/model/Task.java b/src/main/java/com/emobile/springtodo/model/Task.java index ee3074a7..ae20b568 100644 --- a/src/main/java/com/emobile/springtodo/model/Task.java +++ b/src/main/java/com/emobile/springtodo/model/Task.java @@ -16,4 +16,11 @@ public class Task { private boolean completed; private LocalDateTime created_at; private LocalDateTime updated_at; + + public Task(Long id, String description, String title, boolean completed) { + this.id = id; + this.description = description; + this.title = title; + this.completed = completed; + } } diff --git a/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java b/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java index bd7ff6c9..ec8474d3 100644 --- a/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java +++ b/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java @@ -41,6 +41,18 @@ public Optional findById(Long id) { } } + @Override + public Optional> findAllByStatus(boolean isCompleted) { + String SQL = "SELECT * FROM tasks WHERE completed = ?"; + try { + List tasks = jdbcTemplate.query(SQL, new RowMapperImpl(), isCompleted); + log.info("tasks with status {} : {}", isCompleted, tasks); + return Optional.of(tasks); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + @Override public Task save(Task task) { String sql = """ diff --git a/src/main/java/com/emobile/springtodo/repositories/TaskDao.java b/src/main/java/com/emobile/springtodo/repositories/TaskDao.java index 9b627f76..9577be65 100644 --- a/src/main/java/com/emobile/springtodo/repositories/TaskDao.java +++ b/src/main/java/com/emobile/springtodo/repositories/TaskDao.java @@ -12,4 +12,5 @@ public interface TaskDao { Task save(Task task); void delete(Task task); Task update(Long id, Task task); + public Optional> findAllByStatus(boolean isCompleted); } diff --git a/src/main/java/com/emobile/springtodo/services/MetricsService.java b/src/main/java/com/emobile/springtodo/services/MetricsService.java index 09c5bfc9..00733351 100644 --- a/src/main/java/com/emobile/springtodo/services/MetricsService.java +++ b/src/main/java/com/emobile/springtodo/services/MetricsService.java @@ -16,137 +16,24 @@ @Service @RequiredArgsConstructor @Slf4j -public class MetricsService implements MetricsServiceInterface { +public class MetricsService { private final MeterRegistry meterRegistry; - - // Счетчики - private Counter createdTasksCounter; - private Counter completedTasksCounter; - private Counter deletedTasksCounter; - - // Таймеры - private Timer taskCreationTimer; - private Timer taskCompletionTimer; - - // Гейджи - private final AtomicLong activeTasksCount = new AtomicLong(0); - private final AtomicLong completedTasksTotal = new AtomicLong(0); - - // Distribution summaries - private DistributionSummary taskTitleLengthSummary; + private final TaskServiceInterface taskService; @PostConstruct public void init() { - initializeCounters(); - initializeTimers(); initializeGauges(); - initializeDistributionSummaries(); - } - private void initializeCounters() { - createdTasksCounter = Counter.builder("todo.tasks.created") - .description("Total number of created tasks") - .tag("application", "todo-service") - .register(meterRegistry); - - completedTasksCounter = Counter.builder("todo.tasks.completed") - .description("Total number of completed tasks") - .tag("application", "todo-service") - .register(meterRegistry); - - deletedTasksCounter = Counter.builder("todo.tasks.deleted") - .description("Total number of deleted tasks") - .tag("application", "todo-service") - .register(meterRegistry); - } - - private void initializeTimers() { - taskCreationTimer = Timer.builder("todo.tasks.creation.time") - .description("Time taken to create a task") - .tag("application", "todo-service") - .publishPercentiles(0.5, 0.95, 0.99) - .register(meterRegistry); - - taskCompletionTimer = Timer.builder("todo.tasks.completion.time") - .description("Time taken to complete a task") - .tag("application", "todo-service") - .publishPercentiles(0.5, 0.95, 0.99) - .register(meterRegistry); } private void initializeGauges() { - Gauge.builder("todo.tasks.active.count", activeTasksCount, AtomicLong::get) + Gauge.builder("todo.tasks.active.count", taskService, service -> service.countTaskByStatus(false)) .description("Current number of active tasks") .tag("application", "todo-service") .register(meterRegistry); - Gauge.builder("todo.tasks.completed.total", completedTasksTotal, AtomicLong::get) + Gauge.builder("todo.tasks.completed.total", taskService, service -> service.countTaskByStatus(true)) .description("Total number of completed tasks (gauge)") .tag("application", "todo-service") .register(meterRegistry); } - - // Методы для работы с конкретными задачами - public void recordTaskMetrics(Task task) { - if (task.getTitle() != null) { - recordTitleLength(task.getTitle().length()); - } - - if (task.isCompleted()) { - incrementCompletedTasks(); - } - } - - // Метод для сброса метрик (для тестов) - public void reset() { - activeTasksCount.set(0); - completedTasksTotal.set(0); - log.info("Metrics reset performed"); - } - - - private void initializeDistributionSummaries() { - taskTitleLengthSummary = DistributionSummary.builder("todo.tasks.title.length") - .description("Distribution of task title lengths") - .tag("application", "todo-service") - .register(meterRegistry); - } - - public void recordTitleLength(int length) { - taskTitleLengthSummary.record(length); - log.debug("Title length recorded: {}", length); - } - - @Override - public void incrementCreatedTasks() { - createdTasksCounter.increment(); - activeTasksCount.incrementAndGet(); - log.debug("Created tasks counter incremented"); - } - - @Override - public void incrementCompletedTasks() { - completedTasksCounter.increment(); - completedTasksTotal.incrementAndGet(); - activeTasksCount.decrementAndGet(); - log.debug("Completed tasks counter incremented"); - } - - @Override - public void incrementDeletedTasks() { - deletedTasksCounter.increment(); - activeTasksCount.decrementAndGet(); - log.debug("Deleted tasks counter incremented"); - } - - @Override - public void recordTaskCreationTime(long milliseconds) { - taskCreationTimer.record(milliseconds, TimeUnit.MILLISECONDS); - log.debug("Task creation time recorded: {} ms", milliseconds); - } - - @Override - public void recordTaskUpdateTime(long milliseconds) { - taskCreationTimer.record(milliseconds, TimeUnit.MILLISECONDS); - log.debug("Task update time recorded: {} ms", milliseconds); - } } diff --git a/src/main/java/com/emobile/springtodo/services/MetricsServiceInterface.java b/src/main/java/com/emobile/springtodo/services/MetricsServiceInterface.java deleted file mode 100644 index 47f7d1e0..00000000 --- a/src/main/java/com/emobile/springtodo/services/MetricsServiceInterface.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.emobile.springtodo.services; - -public interface MetricsServiceInterface { - void incrementCreatedTasks(); - void incrementCompletedTasks(); - void incrementDeletedTasks(); - void recordTaskCreationTime(long milliseconds); - void recordTaskUpdateTime(long milliseconds); -} \ No newline at end of file diff --git a/src/main/java/com/emobile/springtodo/services/TaskService.java b/src/main/java/com/emobile/springtodo/services/TaskService.java index fd6bc05c..e31aecb2 100644 --- a/src/main/java/com/emobile/springtodo/services/TaskService.java +++ b/src/main/java/com/emobile/springtodo/services/TaskService.java @@ -13,13 +13,8 @@ import org.springframework.cache.annotation.CachePut; import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.Caching; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; - - -import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -30,15 +25,11 @@ public class TaskService implements TaskServiceInterface { private final TaskDao taskRepository; private final TaskMapper taskMapper; - private final MetricsService metricsService; @Override @Cacheable(value = "tasks", key = "'all'", sync = true) public List getAllTasks(int limit, int offset) { - log.info("log getAllTasks()"); Optional> tasksOptional = taskRepository.findAllWithPagination(limit, offset); - List tasksk = tasksOptional.orElse(List.of()); - log.info("service = {}", tasksk); return tasksOptional.orElse(List.of()) .stream() .map(taskMapper::toResponse) @@ -64,19 +55,9 @@ public TaskResponse get(Long taskId) { ) @Override public TaskResponse create(TaskCreateRequest taskCreateRequest) { - long start = System.currentTimeMillis(); - try { - log.info("Creating new todo: {}", taskCreateRequest.getTitle()); Task task = taskMapper.toEntity(taskCreateRequest); Task newTask = taskRepository.save(task); - - metricsService.incrementCreatedTasks(); - metricsService.recordTaskMetrics(task); - return taskMapper.toResponse(newTask); - } finally { - metricsService.recordTaskCreationTime(System.currentTimeMillis() - start); - } } @Transactional @@ -90,20 +71,16 @@ public TaskResponse create(TaskCreateRequest taskCreateRequest) { ) @Override public TaskResponse updateTask(Long id, TaskUpdateRequest taskDetails) { - long start = System.currentTimeMillis(); - try { - log.info("Updating task with id: {}", id); - Task task = taskRepository.findById(id) - .orElseThrow(() -> new TaskNotFoundException("Task not found with id " + id)); + log.info("Updating task with id: {}", id); + Task task = taskRepository.findById(id) + .orElseThrow(() -> new TaskNotFoundException("Task not found with id " + id)); + + taskMapper.updateEntityFromRequest(taskDetails, task); + Task updatedTask = taskRepository.update(id, task); - taskMapper.updateEntityFromRequest(taskDetails, task); - Task updatedTask = taskRepository.update(id, task); + log.info("Task updated successfully: {}", id); + return taskMapper.toResponse(updatedTask); - log.info("Task updated successfully: {}", id); - return taskMapper.toResponse(updatedTask); - } finally { - metricsService.recordTaskUpdateTime(System.currentTimeMillis() - start); - } } @Transactional @@ -118,6 +95,12 @@ public void deleteTask(Long id) { Task task = taskRepository.findById(id) .orElseThrow(() -> new TaskNotFoundException("Task not found with id " + id)); taskRepository.delete(task); - metricsService.incrementDeletedTasks(); + } + + @Override + public int countTaskByStatus(boolean isCompleted) { + List tasks = taskRepository.findAllByStatus(isCompleted) + .orElseThrow(() -> new TaskNotFoundException("Task not found with status " + isCompleted)); + return tasks.size(); } } diff --git a/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java b/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java index 89cccadf..57c6acea 100644 --- a/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java +++ b/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java @@ -22,6 +22,8 @@ public interface TaskServiceInterface { }) TaskResponse updateTask(Long id, TaskUpdateRequest taskDetails); - public void deleteTask(Long id); + int countTaskByStatus(boolean isCompleted); + + void deleteTask(Long id); } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index b987edff..ea1e0580 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,3 +1,13 @@ +server: + port: 8082 + +logging: + level: + org: + hibernate: + SQL: DEBUG +#logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE + spring: redis: host: localhost @@ -20,4 +30,4 @@ management: endpoints: web: exposure: - include: health,metrics,info \ No newline at end of file + include: health,metrics,info,prometheus \ No newline at end of file diff --git a/src/test/java/com/emobile/springtodo/DatabaseTestBase.java b/src/test/java/com/emobile/springtodo/DatabaseTestBase.java new file mode 100644 index 00000000..15bfc3d1 --- /dev/null +++ b/src/test/java/com/emobile/springtodo/DatabaseTestBase.java @@ -0,0 +1,27 @@ +package com.emobile.springtodo; + +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +@Transactional +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public abstract class DatabaseTestBase { + + @Container + private static final PostgreSQLContainer postgresContainer = + new PostgreSQLContainer<>("postgres:15-alpine") + .withReuse(true); + + @DynamicPropertySource + static void postgresProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgresContainer::getJdbcUrl); + registry.add("spring.datasource.username", postgresContainer::getUsername); + registry.add("spring.datasource.password", postgresContainer::getPassword); + } +} diff --git a/src/test/java/com/emobile/springtodo/IntegrationTestBase.java b/src/test/java/com/emobile/springtodo/IntegrationTestBase.java new file mode 100644 index 00000000..56887787 --- /dev/null +++ b/src/test/java/com/emobile/springtodo/IntegrationTestBase.java @@ -0,0 +1,8 @@ +package com.emobile.springtodo; + +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +@AutoConfigureMockMvc +public abstract class IntegrationTestBase extends DatabaseTestBase {} diff --git a/src/test/java/com/emobile/springtodo/controller/TaskControllerTest.java b/src/test/java/com/emobile/springtodo/controller/TaskControllerTest.java new file mode 100644 index 00000000..0933d9cd --- /dev/null +++ b/src/test/java/com/emobile/springtodo/controller/TaskControllerTest.java @@ -0,0 +1,107 @@ +package com.emobile.springtodo.controller; +import com.emobile.springtodo.IntegrationTestBase; +import com.emobile.springtodo.dto.TaskCreateRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class TaskControllerTest extends IntegrationTestBase { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("GET /api/tasks - Успешное получение списка задач") + @Sql("/sql/add-tasks.sql") + void getAllTasks_shouldReturnListOfTasks() throws Exception { + // Act + MvcResult mvcResult = mockMvc.perform(get("/api/tasks?limit=2&offset=0")) + .andExpect(status().isOk()) + .andReturn(); + + // Assert + String actualJsonResponse = mvcResult.getResponse().getContentAsString(); + + String expectedJsonResponse = """ + { + "success": true, + "data": [ + { + "id": 1, + "title": "First Task", + "description": "Description for first task", + "completed": false + }, + { + "id": 2, + "title": "Second Task", + "description": "Description for second task", + "completed": true + } + ] + } + """; + + // Используем строгий режим (true), так как мы описали всю структуру + JSONAssert.assertEquals(expectedJsonResponse, actualJsonResponse, false); + } + + @Test + @DisplayName("GET /api/tasks/{id} - Успешное получение задачи по ID") + @Sql("/sql/add-tasks.sql") + void getTaskById_shouldReturnTask_whenExists() throws Exception { + MvcResult mvcResult = mockMvc.perform(get("/api/tasks/1")) + .andExpect(status().isOk()) + .andReturn(); + + String actualJsonResponse = mvcResult.getResponse().getContentAsString(); + String expectedJsonResponse = """ + { + "success": true, + "data": { "id": 1, "title": "First Task", "description": "Description for first task", "completed": false } + } + """; + JSONAssert.assertEquals(expectedJsonResponse, actualJsonResponse, false); + } + + @Test + @DisplayName("POST /api/tasks - Успешное создание задачи") + void createTask_shouldCreateAndReturnNewTask() throws Exception { + // Arrange + TaskCreateRequest request = new TaskCreateRequest("New shiny task", "Created from test"); + String requestJson = objectMapper.writeValueAsString(request); + + // Act & Assert + mockMvc.perform(post("/api/tasks") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.id").isNumber()) // Проверяем, что ID сгенерирован + .andExpect(jsonPath("$.data.title").value("New shiny task")) + .andExpect(jsonPath("$.data.completed").value(false)); + } + + @Test + @DisplayName("DELETE /api/tasks/{id} - Успешное удаление задачи") + @Sql("/sql/add-tasks.sql") + void deleteTask_shouldReturnSuccessMessage() throws Exception { + mockMvc.perform(delete("/api/tasks/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").value("Task delete successfully")); + } +} \ No newline at end of file diff --git a/src/test/java/com/emobile/springtodo/dao/TaskDaoTest.java b/src/test/java/com/emobile/springtodo/dao/TaskDaoTest.java new file mode 100644 index 00000000..0ac1dd6a --- /dev/null +++ b/src/test/java/com/emobile/springtodo/dao/TaskDaoTest.java @@ -0,0 +1,55 @@ +package com.emobile.springtodo.dao; + +import com.emobile.springtodo.DatabaseTestBase; +import com.emobile.springtodo.model.Task; +import com.emobile.springtodo.repositories.JdbcTemplateTaskDaoImpl; +import com.emobile.springtodo.repositories.TaskDao; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.jdbc.Sql; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class TaskDaoTest extends DatabaseTestBase { + + @Autowired + private TaskDao taskDao; + + @Test + @DisplayName("findById должен найти и вернуть существующую задачу") + @Sql("/sql/add-tasks.sql") + void findById_shouldReturnTask_whenTaskExists() { + // Act + Optional foundTask = taskDao.findById(1L); + + // Assert + assertThat(foundTask).isPresent(); + assertThat(foundTask.get().getTitle()).isEqualTo("First Task"); + assertThat(foundTask.get().isCompleted()).isFalse(); + } + + @Test + @DisplayName("findAllWithPagination должен вернуть правильную страницу задач") + @Sql("/sql/add-tasks.sql") + void findAllWithPagination_shouldReturnCorrectPage() { + // Act + var tasks = taskDao.findAllWithPagination(2, 0).orElseThrow(); + + // Assert + assertThat(tasks).hasSize(2); + assertThat(tasks.get(0).getId()).isEqualTo(1L); + assertThat(tasks.get(1).getId()).isEqualTo(2L); + } +} diff --git a/src/test/java/com/emobile/springtodo/service/TaskServiceTest.java b/src/test/java/com/emobile/springtodo/service/TaskServiceTest.java new file mode 100644 index 00000000..3886a59f --- /dev/null +++ b/src/test/java/com/emobile/springtodo/service/TaskServiceTest.java @@ -0,0 +1,96 @@ +package com.emobile.springtodo.service; + +import com.emobile.springtodo.dto.TaskCreateRequest; +import com.emobile.springtodo.dto.TaskResponse; +import com.emobile.springtodo.exceptions.TaskNotFoundException; +import com.emobile.springtodo.model.Task; +import com.emobile.springtodo.repositories.TaskDao; +import com.emobile.springtodo.services.TaskService; +import com.emobile.springtodo.utils.TaskMapper; +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.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) // Инициализирует моки +class TaskServiceTest { + + @Mock + private TaskDao taskRepository; // Мокаем зависимость + + @Mock + private TaskMapper taskMapper; // Мокаем зависимость + + @InjectMocks + private TaskService taskService; // Создаем экземпляр сервиса и внедряем в него моки + + @Test + @DisplayName("get() должен вернуть TaskResponse, если задача существует") + void get_shouldReturnTaskResponse_whenTaskExists() { + // Arrange (Подготовка) + long taskId = 1L; + Task task = new Task(taskId, "Test Task", "Description", false); + TaskResponse expectedResponse = new TaskResponse(taskId, "Test Task", "Description", false); + + when(taskRepository.findById(taskId)).thenReturn(Optional.of(task)); + when(taskMapper.toResponse(task)).thenReturn(expectedResponse); + + // Act (Действие) + TaskResponse actualResponse = taskService.get(taskId); + + // Assert (Проверка) + assertThat(actualResponse).isNotNull(); + assertThat(actualResponse.getId()).isEqualTo(expectedResponse.getId()); + assertThat(actualResponse.getTitle()).isEqualTo(expectedResponse.getTitle()); + + verify(taskRepository).findById(taskId); // Проверяем, что метод был вызван + verify(taskMapper).toResponse(task); + } + + @Test + @DisplayName("get() должен выбросить TaskNotFoundException, если задача не найдена") + void get_shouldThrowException_whenTaskNotFound() { + // Arrange + long taskId = 99L; + when(taskRepository.findById(taskId)).thenReturn(Optional.empty()); + + // Act & Assert + assertThrows(TaskNotFoundException.class, () -> { + taskService.get(taskId); + }, "Должно быть выброшено исключение TaskNotFoundException"); + + verify(taskRepository).findById(taskId); + verifyNoInteractions(taskMapper); // Маппер не должен был вызываться + } + + @Test + @DisplayName("create() должен сохранить и вернуть новую задачу") + void create_shouldSaveAndReturnNewTask() { + // Arrange + TaskCreateRequest createRequest = new TaskCreateRequest("New Task", "New Desc"); + Task taskToSave = new Task(null, "New Task", "New Desc", false); + Task savedTask = new Task(1L, "New Task", "New Desc", false); + TaskResponse expectedResponse = new TaskResponse(1L, "New Task", "New Desc", false); + + when(taskMapper.toEntity(any(TaskCreateRequest.class))).thenReturn(taskToSave); + when(taskRepository.save(any(Task.class))).thenReturn(savedTask); + when(taskMapper.toResponse(any(Task.class))).thenReturn(expectedResponse); + + // Act + TaskResponse actualResponse = taskService.create(createRequest); + + // Assert + assertThat(actualResponse).isNotNull(); + assertThat(actualResponse.getId()).isEqualTo(1L); + + verify(taskRepository).save(taskToSave); + } +} diff --git a/src/test/resources/sql/add-tasks.sql b/src/test/resources/sql/add-tasks.sql new file mode 100644 index 00000000..ac299bdf --- /dev/null +++ b/src/test/resources/sql/add-tasks.sql @@ -0,0 +1,11 @@ +-- noinspection SqlNoDataSourceInspectionForFile + +-- Очищаем таблицу перед вставкой, чтобы тесты были изолированы +-- (Хотя @Transactional и так это делает, это дополнительная гарантия) +TRUNCATE TABLE tasks RESTART IDENTITY; + +-- Вставляем тестовые данные +INSERT INTO tasks (title, description, completed, created_at, updated_at) VALUES +('First Task', 'Description for first task', false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), +('Second Task', 'Description for second task', true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), +('Third Task', 'Description for third task', false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); \ No newline at end of file From f14d738a0516574fdff0dbcbd5bbd07f126b5449 Mon Sep 17 00:00:00 2001 From: Mihail Yuzhakov Date: Tue, 14 Oct 2025 16:21:06 +0300 Subject: [PATCH 7/7] add pagination info to response --- .../controllers/TaskController.java | 22 +++++++++----- .../emobile/springtodo/dto/PageResponse.java | 29 +++++++++++++++++++ .../springtodo/dto/PaginatedResult.java | 8 +++++ .../com/emobile/springtodo/model/Task.java | 3 +- .../repositories/JdbcTemplateTaskDaoImpl.java | 7 +++++ .../springtodo/repositories/TaskDao.java | 3 +- .../springtodo/services/TaskService.java | 11 +++++-- .../services/TaskServiceInterface.java | 4 +-- 8 files changed, 72 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/emobile/springtodo/dto/PageResponse.java create mode 100644 src/main/java/com/emobile/springtodo/dto/PaginatedResult.java diff --git a/src/main/java/com/emobile/springtodo/controllers/TaskController.java b/src/main/java/com/emobile/springtodo/controllers/TaskController.java index 90f91449..a2a47a14 100644 --- a/src/main/java/com/emobile/springtodo/controllers/TaskController.java +++ b/src/main/java/com/emobile/springtodo/controllers/TaskController.java @@ -1,9 +1,6 @@ package com.emobile.springtodo.controllers; -import com.emobile.springtodo.dto.ApiResponse; -import com.emobile.springtodo.dto.TaskCreateRequest; -import com.emobile.springtodo.dto.TaskResponse; -import com.emobile.springtodo.dto.TaskUpdateRequest; +import com.emobile.springtodo.dto.*; import com.emobile.springtodo.services.TaskServiceInterface; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; @@ -16,6 +13,7 @@ import org.springframework.web.bind.annotation.*; +import java.util.Collections; import java.util.List; @Slf4j @@ -27,12 +25,20 @@ public class TaskController { private final TaskServiceInterface service; @GetMapping - public ApiResponse> getAllTasks( + public ApiResponse> getAllTasks( @RequestParam(defaultValue = "10") @Positive int limit, @RequestParam(defaultValue = "0") @PositiveOrZero int offset) { - log.warn("getAllTask() controller"); - List taskResponses = service.getAllTasks(limit, offset); - return ApiResponse.ok(taskResponses); + + PaginatedResult taskResponses = service.getAllTasks(limit, offset); + + PageResponse pageResponse = new PageResponse<>( + taskResponses.items(), + offset / limit, + limit, + taskResponses.totalCount() + ); + + return ApiResponse.ok(pageResponse); } @GetMapping("/{id}") diff --git a/src/main/java/com/emobile/springtodo/dto/PageResponse.java b/src/main/java/com/emobile/springtodo/dto/PageResponse.java new file mode 100644 index 00000000..0a6b124b --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/PageResponse.java @@ -0,0 +1,29 @@ +package com.emobile.springtodo.dto; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class PageResponse { + private List content; // Сам список элементов на текущей странице + private int currentPage; // Номер текущей страницы (обычно с 0) + private int pageSize; // Количество элементов на странице + private long totalElements; // Общее количество элементов во всей коллекции + private int totalPages; // Общее количество страниц + + public PageResponse(List content, int currentPage, int pageSize, long totalElements) { + this.content = content; + this.currentPage = currentPage; + this.pageSize = pageSize; + this.totalElements = totalElements; + // Рассчитываем общее количество страниц + if (pageSize > 0) { + this.totalPages = (int) Math.ceil((double) totalElements / pageSize); + } else { + this.totalPages = 0; + } + } +} diff --git a/src/main/java/com/emobile/springtodo/dto/PaginatedResult.java b/src/main/java/com/emobile/springtodo/dto/PaginatedResult.java new file mode 100644 index 00000000..ed1dc027 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/PaginatedResult.java @@ -0,0 +1,8 @@ +package com.emobile.springtodo.dto; + +import java.util.List; + +public record PaginatedResult( + List items, + long totalCount +) {} diff --git a/src/main/java/com/emobile/springtodo/model/Task.java b/src/main/java/com/emobile/springtodo/model/Task.java index ae20b568..50bac2ce 100644 --- a/src/main/java/com/emobile/springtodo/model/Task.java +++ b/src/main/java/com/emobile/springtodo/model/Task.java @@ -5,7 +5,8 @@ import java.time.LocalDateTime; -@Data +@Getter +@Setter @AllArgsConstructor @NoArgsConstructor public class Task { diff --git a/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java b/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java index ec8474d3..9ec99f16 100644 --- a/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java +++ b/src/main/java/com/emobile/springtodo/repositories/JdbcTemplateTaskDaoImpl.java @@ -53,6 +53,13 @@ public Optional> findAllByStatus(boolean isCompleted) { } } + @Override + public Integer countTasks() { + String SQL = "SELECT COUNT(*) FROM tasks"; + Integer count = jdbcTemplate.queryForObject(SQL, Integer.class); + return (count != null) ? count : 0; + } + @Override public Task save(Task task) { String sql = """ diff --git a/src/main/java/com/emobile/springtodo/repositories/TaskDao.java b/src/main/java/com/emobile/springtodo/repositories/TaskDao.java index 9577be65..64693dc8 100644 --- a/src/main/java/com/emobile/springtodo/repositories/TaskDao.java +++ b/src/main/java/com/emobile/springtodo/repositories/TaskDao.java @@ -12,5 +12,6 @@ public interface TaskDao { Task save(Task task); void delete(Task task); Task update(Long id, Task task); - public Optional> findAllByStatus(boolean isCompleted); + Optional> findAllByStatus(boolean isCompleted); + Integer countTasks(); } diff --git a/src/main/java/com/emobile/springtodo/services/TaskService.java b/src/main/java/com/emobile/springtodo/services/TaskService.java index e31aecb2..ddb8978d 100644 --- a/src/main/java/com/emobile/springtodo/services/TaskService.java +++ b/src/main/java/com/emobile/springtodo/services/TaskService.java @@ -1,5 +1,6 @@ package com.emobile.springtodo.services; +import com.emobile.springtodo.dto.PaginatedResult; import com.emobile.springtodo.dto.TaskCreateRequest; import com.emobile.springtodo.dto.TaskResponse; import com.emobile.springtodo.dto.TaskUpdateRequest; @@ -28,12 +29,16 @@ public class TaskService implements TaskServiceInterface { @Override @Cacheable(value = "tasks", key = "'all'", sync = true) - public List getAllTasks(int limit, int offset) { + public PaginatedResult getAllTasks(int limit, int offset) { Optional> tasksOptional = taskRepository.findAllWithPagination(limit, offset); - return tasksOptional.orElse(List.of()) + List taskResponses = tasksOptional.orElse(List.of()) .stream() .map(taskMapper::toResponse) - .collect(Collectors.toList()); + .toList(); + + int totalCount = taskRepository.countTasks(); + + return new PaginatedResult<>(taskResponses, totalCount); } @Override diff --git a/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java b/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java index 57c6acea..9a335a31 100644 --- a/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java +++ b/src/main/java/com/emobile/springtodo/services/TaskServiceInterface.java @@ -1,5 +1,6 @@ package com.emobile.springtodo.services; +import com.emobile.springtodo.dto.PaginatedResult; import com.emobile.springtodo.dto.TaskCreateRequest; import com.emobile.springtodo.dto.TaskResponse; import com.emobile.springtodo.dto.TaskUpdateRequest; @@ -9,7 +10,7 @@ import java.util.List; public interface TaskServiceInterface { - List getAllTasks(int limit, int offset); + PaginatedResult getAllTasks(int limit, int offset); TaskResponse get(Long taskId); TaskResponse create(TaskCreateRequest taskCreateRequest); @@ -25,5 +26,4 @@ public interface TaskServiceInterface { int countTaskByStatus(boolean isCompleted); void deleteTask(Long id); - }