From 7b3704366a0f05d68a2ebc178e1c49b8e2fcdf31 Mon Sep 17 00:00:00 2001 From: ViktorDolzenko Date: Tue, 17 Feb 2026 18:29:53 +0200 Subject: [PATCH 1/5] ViktorsDolzeno-task/2 --- .../exception/GlobalExceptionHandler.java | 10 ++ .../statistics/StatisticsController.java | 71 ++++++++ .../statistics/StatisticsService.java | 151 +++++++++++++++++ .../models/StatisticsDetailedDto.java | 10 ++ .../models/StatisticsSummaryDto.java | 6 + .../models/StatisticsTodoItemDto.java | 6 + .../statistics/models/StatisticsTodosDto.java | 6 + .../features/todo/TodoRepository.java | 7 + .../features/todo/TodoService.java | 12 ++ .../StatisticsControllerIntegrationTest.java | 78 +++++++++ .../StatisticsServiceIntegrationTest.java | 154 ++++++++++++++++++ 11 files changed, 511 insertions(+) create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsDetailedDto.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsSummaryDto.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsTodoItemDto.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsTodosDto.java create mode 100644 src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerIntegrationTest.java create mode 100644 src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsServiceIntegrationTest.java diff --git a/src/main/java/lv/ctco/springboottemplate/exception/GlobalExceptionHandler.java b/src/main/java/lv/ctco/springboottemplate/exception/GlobalExceptionHandler.java index ec9cb49..dcf7d3c 100644 --- a/src/main/java/lv/ctco/springboottemplate/exception/GlobalExceptionHandler.java +++ b/src/main/java/lv/ctco/springboottemplate/exception/GlobalExceptionHandler.java @@ -4,10 +4,20 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.server.ResponseStatusException; @RestControllerAdvice public class GlobalExceptionHandler { + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity handleResponseStatusException(ResponseStatusException ex) { + HttpStatus status = HttpStatus.resolve(ex.getStatusCode().value()); + HttpStatus effectiveStatus = status != null ? status : HttpStatus.INTERNAL_SERVER_ERROR; + String reason = ex.getReason() != null ? ex.getReason() : "Request failed"; + + return ResponseEntity.status(effectiveStatus).body(reason); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception ex) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java new file mode 100644 index 0000000..03de2dc --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java @@ -0,0 +1,71 @@ +package lv.ctco.springboottemplate.features.statistics; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsDetailedDto; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsSummaryDto; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/statistics") +@Tag(name = "Statistics Controller", description = "Todo statistics endpoints") +public class StatisticsController { + + private final StatisticsService statisticsService; + + public StatisticsController(StatisticsService statisticsService) { + this.statisticsService = statisticsService; + } + + @GetMapping + @Operation(summary = "Get todo statistics") + public ResponseEntity getStatistics( + @RequestParam(required = false) String from, + @RequestParam(required = false) String to, + @RequestParam(defaultValue = "summary") String format) { + + LocalDate fromDate = parseDateOrNull(from, "from"); + LocalDate toDate = parseDateOrNull(to, "to"); + + if (!format.equalsIgnoreCase("summary") && !format.equalsIgnoreCase("detailed")) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "format must be either 'summary' or 'detailed'"); + } + + if (fromDate != null && toDate != null && fromDate.isAfter(toDate)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "'from' date must be before or equal to 'to' date"); + } + + if (format.equalsIgnoreCase("summary")) { + StatisticsSummaryDto summary = statisticsService.getSummary(fromDate, toDate); + return ResponseEntity.ok(summary); + } + + StatisticsDetailedDto detailed = statisticsService.getDetailed(fromDate, toDate); + return ResponseEntity.ok(detailed); + } + + private LocalDate parseDateOrNull(String raw, String paramName) { + if (raw == null || raw.isBlank()) { + return null; + } + + try { + return LocalDate.parse(raw); + } catch (DateTimeParseException ex) { + String message = + String.format("Invalid '%s' date. Expected format is yyyy-MM-dd.", paramName); + + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message, ex); + } + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java new file mode 100644 index 0000000..128019c --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java @@ -0,0 +1,151 @@ +package lv.ctco.springboottemplate.features.statistics; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsDetailedDto; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsSummaryDto; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsTodoItemDto; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsTodosDto; +import lv.ctco.springboottemplate.features.todo.Todo; +import lv.ctco.springboottemplate.features.todo.TodoService; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.data.mongodb.core.aggregation.GroupOperation; +import org.springframework.data.mongodb.core.aggregation.MatchOperation; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.stereotype.Service; + +@Service +public class StatisticsService { + + private final TodoService todoService; + private final MongoTemplate mongoTemplate; + + public StatisticsService(TodoService todoService, MongoTemplate mongoTemplate) { + this.todoService = todoService; + this.mongoTemplate = mongoTemplate; + } + + public StatisticsSummaryDto getSummary(LocalDate from, LocalDate to) { + return aggregateSummary(from, to); + } + + public StatisticsDetailedDto getDetailed(LocalDate from, LocalDate to) { + StatisticsSummaryDto summary = aggregateSummary(from, to); + List todos = findTodosInRange(from, to); + StatisticsTodosDto todosDto = buildTodosDto(todos); + + return new StatisticsDetailedDto( + summary.totalTodos(), + summary.completedTodos(), + summary.pendingTodos(), + summary.userStats(), + todosDto); + } + + private StatisticsSummaryDto aggregateSummary(LocalDate from, LocalDate to) { + Aggregation aggregation = buildAggregation(from, to); + AggregationResults results = + mongoTemplate.aggregate(aggregation, "todos", Document.class); + return mapResultsToSummary(results); + } + + private Aggregation buildAggregation(LocalDate from, LocalDate to) { + Criteria dateCriteria = buildDateCriteria(from, to); + + GroupOperation groupByUserAndCompletion = + Aggregation.group("createdBy", "completed").count().as("count"); + + if (dateCriteria != null) { + MatchOperation match = Aggregation.match(dateCriteria); + return Aggregation.newAggregation(match, groupByUserAndCompletion); + } + + return Aggregation.newAggregation(groupByUserAndCompletion); + } + + private StatisticsSummaryDto mapResultsToSummary(AggregationResults results) { + long total = 0L; + long completed = 0L; + Map userStats = new HashMap<>(); + + for (Document doc : results) { + Document id = (Document) doc.get("_id"); + String createdBy = id.getString("createdBy"); + boolean isCompleted = Boolean.TRUE.equals(id.getBoolean("completed")); + Number countNumber = doc.get("count", Number.class); + long count = countNumber != null ? countNumber.longValue() : 0L; + + total += count; + if (isCompleted) { + completed += count; + } + userStats.merge(createdBy, count, Long::sum); + } + + long pending = total - completed; + return new StatisticsSummaryDto(total, completed, pending, userStats); + } + + private List findTodosInRange(LocalDate from, LocalDate to) { + Instant fromInstant = from != null ? atStartOfDay(from) : null; + Instant toInstant = to != null ? atEndOfDay(to) : null; + + return todoService.getTodosByCreatedAtRange(fromInstant, toInstant); + } + + private Criteria buildDateCriteria(LocalDate from, LocalDate to) { + Instant fromInstant = from != null ? atStartOfDay(from) : null; + Instant toInstant = to != null ? atEndOfDay(to) : null; + + if (fromInstant != null && toInstant != null) { + return Criteria.where("createdAt").gte(fromInstant).lte(toInstant); + } else if (fromInstant != null) { + return Criteria.where("createdAt").gte(fromInstant); + } else if (toInstant != null) { + return Criteria.where("createdAt").lte(toInstant); + } + + return null; + } + + private Instant atStartOfDay(LocalDate date) { + return date.atStartOfDay().toInstant(ZoneOffset.UTC); + } + + private Instant atEndOfDay(LocalDate date) { + return date.atTime(23, 59, 59).toInstant(ZoneOffset.UTC); + } + + private StatisticsTodosDto buildTodosDto(List todos) { + List completedTodos = + todos.stream() + .filter(Todo::completed) + .map( + todo -> + new StatisticsTodoItemDto( + todo.id(), + todo.title(), + todo.createdBy(), + todo.createdAt(), + todo.updatedAt())) + .toList(); + + List pendingTodos = + todos.stream() + .filter(todo -> !todo.completed()) + .map( + todo -> + new StatisticsTodoItemDto( + todo.id(), todo.title(), todo.createdBy(), todo.createdAt(), null)) + .toList(); + + return new StatisticsTodosDto(completedTodos, pendingTodos); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsDetailedDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsDetailedDto.java new file mode 100644 index 0000000..cdf1f7e --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsDetailedDto.java @@ -0,0 +1,10 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import java.util.Map; + +public record StatisticsDetailedDto( + long totalTodos, + long completedTodos, + long pendingTodos, + Map userStats, + StatisticsTodosDto todos) {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsSummaryDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsSummaryDto.java new file mode 100644 index 0000000..3c36c68 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsSummaryDto.java @@ -0,0 +1,6 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import java.util.Map; + +public record StatisticsSummaryDto( + long totalTodos, long completedTodos, long pendingTodos, Map userStats) {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsTodoItemDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsTodoItemDto.java new file mode 100644 index 0000000..9ecaf16 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsTodoItemDto.java @@ -0,0 +1,6 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import java.time.Instant; + +public record StatisticsTodoItemDto( + String id, String title, String createdBy, Instant createdAt, Instant completedAt) {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsTodosDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsTodosDto.java new file mode 100644 index 0000000..d536ea6 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsTodosDto.java @@ -0,0 +1,6 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import java.util.List; + +public record StatisticsTodosDto( + List completed, List pending) {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoRepository.java b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoRepository.java index 961b95d..1bc6771 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoRepository.java +++ b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoRepository.java @@ -1,5 +1,6 @@ package lv.ctco.springboottemplate.features.todo; +import java.time.Instant; import java.util.List; import org.springframework.data.mongodb.repository.MongoRepository; import org.springframework.stereotype.Repository; @@ -27,4 +28,10 @@ @Repository public interface TodoRepository extends MongoRepository { List findByTitleContainingIgnoreCase(String title); + + List findByCreatedAtBetween(Instant from, Instant to); + + List findByCreatedAtGreaterThanEqual(Instant from); + + List findByCreatedAtLessThanEqual(Instant to); } diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java index 3aada7a..a7a378e 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java @@ -81,4 +81,16 @@ public boolean deleteTodo(String id) { } return false; } + + public List getTodosByCreatedAtRange(Instant from, Instant to) { + if (from != null && to != null) { + return todoRepository.findByCreatedAtBetween(from, to); + } else if (from != null) { + return todoRepository.findByCreatedAtGreaterThanEqual(from); + } else if (to != null) { + return todoRepository.findByCreatedAtLessThanEqual(to); + } + + return todoRepository.findAll(); + } } diff --git a/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerIntegrationTest.java b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerIntegrationTest.java new file mode 100644 index 0000000..1790773 --- /dev/null +++ b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerIntegrationTest.java @@ -0,0 +1,78 @@ +package lv.ctco.springboottemplate.features.statistics; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsSummaryDto; +import lv.ctco.springboottemplate.features.todo.Todo; +import lv.ctco.springboottemplate.features.todo.TodoRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Testcontainers +class StatisticsControllerIntegrationTest { + + @Container static MongoDBContainer mongoDBContainer = new MongoDBContainer("mongo:6.0.8"); + + @DynamicPropertySource + static void setProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.mongodb.uri", mongoDBContainer::getReplicaSetUrl); + } + + @LocalServerPort private int port; + + @Autowired private TestRestTemplate restTemplate; + + @Autowired private TodoRepository todoRepository; + + @BeforeEach + void setup() { + todoRepository.deleteAll(); + todoRepository.save( + new Todo( + null, + "Sample", + "Sample desc", + false, + "user1", + "user1", + Instant.parse("2024-01-10T10:00:00Z"), + Instant.parse("2024-01-10T10:00:00Z"))); + } + + @Test + void should_return_summary_statistics_via_http() { + String url = "http://localhost:" + port + "/api/statistics?format=summary"; + + ResponseEntity response = + restTemplate.getForEntity(url, StatisticsSummaryDto.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + StatisticsSummaryDto body = response.getBody(); + assertThat(body).isNotNull(); + assertThat(body.totalTodos()).isEqualTo(1); + assertThat(body.pendingTodos()).isEqualTo(1); + assertThat(body.completedTodos()).isZero(); + } + + @Test + void should_return_bad_request_for_invalid_date() { + String url = "http://localhost:" + port + "/api/statistics?from=2024-99-99"; + + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } +} diff --git a/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsServiceIntegrationTest.java b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsServiceIntegrationTest.java new file mode 100644 index 0000000..ce2c003 --- /dev/null +++ b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsServiceIntegrationTest.java @@ -0,0 +1,154 @@ +package lv.ctco.springboottemplate.features.statistics; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.time.LocalDate; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsDetailedDto; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsSummaryDto; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsTodosDto; +import lv.ctco.springboottemplate.features.todo.Todo; +import lv.ctco.springboottemplate.features.todo.TodoRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.TestConstructor; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@SpringBootTest +@Testcontainers +@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) +class StatisticsServiceIntegrationTest { + + @Container static MongoDBContainer mongoDBContainer = new MongoDBContainer("mongo:6.0.8"); + + @DynamicPropertySource + static void setProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.mongodb.uri", mongoDBContainer::getReplicaSetUrl); + } + + private final TodoRepository todoRepository; + private final StatisticsService statisticsService; + + StatisticsServiceIntegrationTest( + TodoRepository todoRepository, StatisticsService statisticsService) { + this.todoRepository = todoRepository; + this.statisticsService = statisticsService; + } + + @BeforeEach + void clean() { + todoRepository.deleteAll(); + } + + @Test + void should_calculate_summary_without_filters() { + // given + todoRepository.save( + new Todo( + null, + "Todo 1", + "First", + false, + "user1", + "user1", + Instant.parse("2024-01-10T10:00:00Z"), + Instant.parse("2024-01-10T10:00:00Z"))); + todoRepository.save( + new Todo( + null, + "Todo 2", + "Second", + true, + "user1", + "user1", + Instant.parse("2024-01-11T10:00:00Z"), + Instant.parse("2024-01-12T10:00:00Z"))); + todoRepository.save( + new Todo( + null, + "Todo 3", + "Third", + false, + "user2", + "user2", + Instant.parse("2024-01-12T10:00:00Z"), + Instant.parse("2024-01-12T10:00:00Z"))); + + // when + StatisticsSummaryDto summary = statisticsService.getSummary(null, null); + + // then + assertThat(summary.totalTodos()).isEqualTo(3); + assertThat(summary.completedTodos()).isEqualTo(1); + assertThat(summary.pendingTodos()).isEqualTo(2); + assertThat(summary.userStats()).containsEntry("user1", 2L).containsEntry("user2", 1L); + } + + @Test + void should_calculate_detailed_statistics_for_date_range() { + // given + todoRepository.save( + new Todo( + null, + "Old todo", + "Outside range", + false, + "user1", + "user1", + Instant.parse("2023-12-31T23:00:00Z"), + Instant.parse("2023-12-31T23:00:00Z"))); + + todoRepository.save( + new Todo( + null, + "In range 1", + "First in range", + false, + "user1", + "user1", + Instant.parse("2024-01-01T10:00:00Z"), + Instant.parse("2024-01-01T10:00:00Z"))); + todoRepository.save( + new Todo( + null, + "In range 2", + "Completed in range", + true, + "user2", + "user2", + Instant.parse("2024-01-05T10:00:00Z"), + Instant.parse("2024-01-06T10:00:00Z"))); + + todoRepository.save( + new Todo( + null, + "After range", + "After", + false, + "user3", + "user3", + Instant.parse("2024-02-01T10:00:00Z"), + Instant.parse("2024-02-01T10:00:00Z"))); + + LocalDate from = LocalDate.parse("2024-01-01"); + LocalDate to = LocalDate.parse("2024-01-31"); + + // when + StatisticsDetailedDto detailed = statisticsService.getDetailed(from, to); + + // then + assertThat(detailed.totalTodos()).isEqualTo(2); + assertThat(detailed.completedTodos()).isEqualTo(1); + assertThat(detailed.pendingTodos()).isEqualTo(1); + assertThat(detailed.userStats()).containsEntry("user1", 1L).containsEntry("user2", 1L); + + StatisticsTodosDto todos = detailed.todos(); + assertThat(todos.completed()).hasSize(1); + assertThat(todos.pending()).hasSize(1); + } +} From 3bf9ac43fc624d12794f3e2e32ab2356b21ab224 Mon Sep 17 00:00:00 2001 From: ViktorDolzenko Date: Tue, 17 Feb 2026 18:55:42 +0200 Subject: [PATCH 2/5] bsod out of loop --- .../statistics/StatisticsService.java | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java index 128019c..fbf4abf 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java @@ -76,23 +76,29 @@ private StatisticsSummaryDto mapResultsToSummary(AggregationResults re Map userStats = new HashMap<>(); for (Document doc : results) { - Document id = (Document) doc.get("_id"); - String createdBy = id.getString("createdBy"); - boolean isCompleted = Boolean.TRUE.equals(id.getBoolean("completed")); - Number countNumber = doc.get("count", Number.class); - long count = countNumber != null ? countNumber.longValue() : 0L; - - total += count; - if (isCompleted) { - completed += count; + AggregatedRow row = toAggregatedRow(doc); + + total += row.count(); + if (row.completed()) { + completed += row.count(); } - userStats.merge(createdBy, count, Long::sum); + userStats.merge(row.createdBy(), row.count(), Long::sum); } long pending = total - completed; return new StatisticsSummaryDto(total, completed, pending, userStats); } + private AggregatedRow toAggregatedRow(Document doc) { + Document id = (Document) doc.get("_id"); + String createdBy = id.getString("createdBy"); + boolean isCompleted = Boolean.TRUE.equals(id.getBoolean("completed")); + Number countNumber = doc.get("count", Number.class); + long count = countNumber != null ? countNumber.longValue() : 0L; + + return new AggregatedRow(createdBy, isCompleted, count); + } + private List findTodosInRange(LocalDate from, LocalDate to) { Instant fromInstant = from != null ? atStartOfDay(from) : null; Instant toInstant = to != null ? atEndOfDay(to) : null; @@ -148,4 +154,6 @@ private StatisticsTodosDto buildTodosDto(List todos) { return new StatisticsTodosDto(completedTodos, pendingTodos); } + + private record AggregatedRow(String createdBy, boolean completed, long count) {} } From 0ce85a5e520a34f0b21476cf0e11056cb8ebdf3b Mon Sep 17 00:00:00 2001 From: ViktorDolzenko Date: Tue, 17 Feb 2026 20:27:51 +0200 Subject: [PATCH 3/5] separate mapper --- .../features/statistics/StatisticsMapper.java | 84 +++++++++++++++++++ .../statistics/StatisticsService.java | 77 ++--------------- 2 files changed, 91 insertions(+), 70 deletions(-) create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsMapper.java diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsMapper.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsMapper.java new file mode 100644 index 0000000..7839e57 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsMapper.java @@ -0,0 +1,84 @@ +package lv.ctco.springboottemplate.features.statistics; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsDetailedDto; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsSummaryDto; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsTodoItemDto; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsTodosDto; +import lv.ctco.springboottemplate.features.todo.Todo; +import org.bson.Document; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.stereotype.Component; + +@Component +public class StatisticsMapper { + + public StatisticsSummaryDto toSummary(AggregationResults results) { + long total = 0L; + long completed = 0L; + Map userStats = new HashMap<>(); + + for (Document doc : results) { + AggregatedRow row = toAggregatedRow(doc); + + total += row.count(); + if (row.completed()) { + completed += row.count(); + } + userStats.merge(row.createdBy(), row.count(), Long::sum); + } + + long pending = total - completed; + return new StatisticsSummaryDto(total, completed, pending, userStats); + } + + public StatisticsDetailedDto toDetailed( + StatisticsSummaryDto summary, StatisticsTodosDto todosDto) { + return new StatisticsDetailedDto( + summary.totalTodos(), + summary.completedTodos(), + summary.pendingTodos(), + summary.userStats(), + todosDto); + } + + public StatisticsTodosDto toTodosDto(List todos) { + List completedTodos = + todos.stream() + .filter(Todo::completed) + .map( + todo -> + new StatisticsTodoItemDto( + todo.id(), + todo.title(), + todo.createdBy(), + todo.createdAt(), + todo.updatedAt())) + .toList(); + + List pendingTodos = + todos.stream() + .filter(todo -> !todo.completed()) + .map( + todo -> + new StatisticsTodoItemDto( + todo.id(), todo.title(), todo.createdBy(), todo.createdAt(), null)) + .toList(); + + return new StatisticsTodosDto(completedTodos, pendingTodos); + } + + private AggregatedRow toAggregatedRow(Document doc) { + Document id = (Document) doc.get("_id"); + String createdBy = id.getString("createdBy"); + boolean isCompleted = Boolean.TRUE.equals(id.getBoolean("completed")); + Number countNumber = doc.get("count", Number.class); + long count = countNumber != null ? countNumber.longValue() : 0L; + + return new AggregatedRow(createdBy, isCompleted, count); + } + + private record AggregatedRow(String createdBy, boolean completed, long count) {} +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java index fbf4abf..8b4c5c6 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java @@ -3,12 +3,9 @@ import java.time.Instant; import java.time.LocalDate; import java.time.ZoneOffset; -import java.util.HashMap; import java.util.List; -import java.util.Map; import lv.ctco.springboottemplate.features.statistics.models.StatisticsDetailedDto; import lv.ctco.springboottemplate.features.statistics.models.StatisticsSummaryDto; -import lv.ctco.springboottemplate.features.statistics.models.StatisticsTodoItemDto; import lv.ctco.springboottemplate.features.statistics.models.StatisticsTodosDto; import lv.ctco.springboottemplate.features.todo.Todo; import lv.ctco.springboottemplate.features.todo.TodoService; @@ -26,10 +23,13 @@ public class StatisticsService { private final TodoService todoService; private final MongoTemplate mongoTemplate; + private final StatisticsMapper statisticsMapper; - public StatisticsService(TodoService todoService, MongoTemplate mongoTemplate) { + public StatisticsService( + TodoService todoService, MongoTemplate mongoTemplate, StatisticsMapper statisticsMapper) { this.todoService = todoService; this.mongoTemplate = mongoTemplate; + this.statisticsMapper = statisticsMapper; } public StatisticsSummaryDto getSummary(LocalDate from, LocalDate to) { @@ -39,21 +39,15 @@ public StatisticsSummaryDto getSummary(LocalDate from, LocalDate to) { public StatisticsDetailedDto getDetailed(LocalDate from, LocalDate to) { StatisticsSummaryDto summary = aggregateSummary(from, to); List todos = findTodosInRange(from, to); - StatisticsTodosDto todosDto = buildTodosDto(todos); - - return new StatisticsDetailedDto( - summary.totalTodos(), - summary.completedTodos(), - summary.pendingTodos(), - summary.userStats(), - todosDto); + StatisticsTodosDto todosDto = statisticsMapper.toTodosDto(todos); + return statisticsMapper.toDetailed(summary, todosDto); } private StatisticsSummaryDto aggregateSummary(LocalDate from, LocalDate to) { Aggregation aggregation = buildAggregation(from, to); AggregationResults results = mongoTemplate.aggregate(aggregation, "todos", Document.class); - return mapResultsToSummary(results); + return statisticsMapper.toSummary(results); } private Aggregation buildAggregation(LocalDate from, LocalDate to) { @@ -70,35 +64,6 @@ private Aggregation buildAggregation(LocalDate from, LocalDate to) { return Aggregation.newAggregation(groupByUserAndCompletion); } - private StatisticsSummaryDto mapResultsToSummary(AggregationResults results) { - long total = 0L; - long completed = 0L; - Map userStats = new HashMap<>(); - - for (Document doc : results) { - AggregatedRow row = toAggregatedRow(doc); - - total += row.count(); - if (row.completed()) { - completed += row.count(); - } - userStats.merge(row.createdBy(), row.count(), Long::sum); - } - - long pending = total - completed; - return new StatisticsSummaryDto(total, completed, pending, userStats); - } - - private AggregatedRow toAggregatedRow(Document doc) { - Document id = (Document) doc.get("_id"); - String createdBy = id.getString("createdBy"); - boolean isCompleted = Boolean.TRUE.equals(id.getBoolean("completed")); - Number countNumber = doc.get("count", Number.class); - long count = countNumber != null ? countNumber.longValue() : 0L; - - return new AggregatedRow(createdBy, isCompleted, count); - } - private List findTodosInRange(LocalDate from, LocalDate to) { Instant fromInstant = from != null ? atStartOfDay(from) : null; Instant toInstant = to != null ? atEndOfDay(to) : null; @@ -128,32 +93,4 @@ private Instant atStartOfDay(LocalDate date) { private Instant atEndOfDay(LocalDate date) { return date.atTime(23, 59, 59).toInstant(ZoneOffset.UTC); } - - private StatisticsTodosDto buildTodosDto(List todos) { - List completedTodos = - todos.stream() - .filter(Todo::completed) - .map( - todo -> - new StatisticsTodoItemDto( - todo.id(), - todo.title(), - todo.createdBy(), - todo.createdAt(), - todo.updatedAt())) - .toList(); - - List pendingTodos = - todos.stream() - .filter(todo -> !todo.completed()) - .map( - todo -> - new StatisticsTodoItemDto( - todo.id(), todo.title(), todo.createdBy(), todo.createdAt(), null)) - .toList(); - - return new StatisticsTodosDto(completedTodos, pendingTodos); - } - - private record AggregatedRow(String createdBy, boolean completed, long count) {} } From ee8f6ae4bbf3f0e75ca6f23671851bca4de8358f Mon Sep 17 00:00:00 2001 From: ViktorDolzenko Date: Tue, 17 Feb 2026 20:44:41 +0200 Subject: [PATCH 4/5] ArchUnit fix --- ...icsMapper.java => StatisticsMapperService.java} | 6 +++--- .../features/statistics/StatisticsService.java | 14 ++++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) rename src/main/java/lv/ctco/springboottemplate/features/statistics/{StatisticsMapper.java => StatisticsMapperService.java} (96%) diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsMapper.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsMapperService.java similarity index 96% rename from src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsMapper.java rename to src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsMapperService.java index 7839e57..6386d41 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsMapper.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsMapperService.java @@ -10,10 +10,10 @@ import lv.ctco.springboottemplate.features.todo.Todo; import org.bson.Document; import org.springframework.data.mongodb.core.aggregation.AggregationResults; -import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; -@Component -public class StatisticsMapper { +@Service +public class StatisticsMapperService { public StatisticsSummaryDto toSummary(AggregationResults results) { long total = 0L; diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java index 8b4c5c6..fe6a38b 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java @@ -23,13 +23,15 @@ public class StatisticsService { private final TodoService todoService; private final MongoTemplate mongoTemplate; - private final StatisticsMapper statisticsMapper; + private final StatisticsMapperService statisticsMapperService; public StatisticsService( - TodoService todoService, MongoTemplate mongoTemplate, StatisticsMapper statisticsMapper) { + TodoService todoService, + MongoTemplate mongoTemplate, + StatisticsMapperService statisticsMapperService) { this.todoService = todoService; this.mongoTemplate = mongoTemplate; - this.statisticsMapper = statisticsMapper; + this.statisticsMapperService = statisticsMapperService; } public StatisticsSummaryDto getSummary(LocalDate from, LocalDate to) { @@ -39,15 +41,15 @@ public StatisticsSummaryDto getSummary(LocalDate from, LocalDate to) { public StatisticsDetailedDto getDetailed(LocalDate from, LocalDate to) { StatisticsSummaryDto summary = aggregateSummary(from, to); List todos = findTodosInRange(from, to); - StatisticsTodosDto todosDto = statisticsMapper.toTodosDto(todos); - return statisticsMapper.toDetailed(summary, todosDto); + StatisticsTodosDto todosDto = statisticsMapperService.toTodosDto(todos); + return statisticsMapperService.toDetailed(summary, todosDto); } private StatisticsSummaryDto aggregateSummary(LocalDate from, LocalDate to) { Aggregation aggregation = buildAggregation(from, to); AggregationResults results = mongoTemplate.aggregate(aggregation, "todos", Document.class); - return statisticsMapper.toSummary(results); + return statisticsMapperService.toSummary(results); } private Aggregation buildAggregation(LocalDate from, LocalDate to) { From 54b8a1d85e0363b54a563e9207dcddb5f36e0299 Mon Sep 17 00:00:00 2001 From: ViktorDolzenko Date: Wed, 18 Feb 2026 14:09:30 +0200 Subject: [PATCH 5/5] added validation and messagesource --- .../exception/GlobalExceptionHandler.java | 42 ++++++++++- .../statistics/StatisticsController.java | 44 ++--------- .../models/StatisticsRequestDto.java | 28 +++++++ .../statistics/ValidStatisticsRequest.java | 19 +++++ .../ValidStatisticsRequestValidator.java | 73 +++++++++++++++++++ src/main/resources/messages.properties | 7 ++ 6 files changed, 174 insertions(+), 39 deletions(-) create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsRequestDto.java create mode 100644 src/main/java/lv/ctco/springboottemplate/validation/statistics/ValidStatisticsRequest.java create mode 100644 src/main/java/lv/ctco/springboottemplate/validation/statistics/ValidStatisticsRequestValidator.java create mode 100644 src/main/resources/messages.properties diff --git a/src/main/java/lv/ctco/springboottemplate/exception/GlobalExceptionHandler.java b/src/main/java/lv/ctco/springboottemplate/exception/GlobalExceptionHandler.java index dcf7d3c..933e5ab 100644 --- a/src/main/java/lv/ctco/springboottemplate/exception/GlobalExceptionHandler.java +++ b/src/main/java/lv/ctco/springboottemplate/exception/GlobalExceptionHandler.java @@ -1,7 +1,12 @@ package lv.ctco.springboottemplate.exception; +import jakarta.validation.ConstraintViolationException; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.server.ResponseStatusException; @@ -9,18 +14,51 @@ @RestControllerAdvice public class GlobalExceptionHandler { + private final MessageSource messageSource; + + public GlobalExceptionHandler(MessageSource messageSource) { + this.messageSource = messageSource; + } + @ExceptionHandler(ResponseStatusException.class) public ResponseEntity handleResponseStatusException(ResponseStatusException ex) { HttpStatus status = HttpStatus.resolve(ex.getStatusCode().value()); HttpStatus effectiveStatus = status != null ? status : HttpStatus.INTERNAL_SERVER_ERROR; - String reason = ex.getReason() != null ? ex.getReason() : "Request failed"; + String reason = ex.getReason() != null ? ex.getReason() : message("errors.request.failed"); return ResponseEntity.status(effectiveStatus).body(reason); } + @ExceptionHandler(BindException.class) + public ResponseEntity handleBindException(BindException ex) { + String message = + ex.getBindingResult().getAllErrors().stream() + .findFirst() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .orElse(message("errors.invalid.request.parameters")); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(message); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException( + ConstraintViolationException ex) { + String message = + ex.getConstraintViolations().stream() + .findFirst() + .map(violation -> violation.getMessage()) + .orElse(message("errors.invalid.request.parameters")); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(message); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception ex) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body("Oops, something went wrong, try later"); + .body(message("errors.unexpected")); + } + + private String message(String code, Object... args) { + return messageSource.getMessage(code, args, LocaleContextHolder.getLocale()); } } diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java index 03de2dc..eeddaac 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java @@ -2,17 +2,16 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import java.time.LocalDate; -import java.time.format.DateTimeParseException; import lv.ctco.springboottemplate.features.statistics.models.StatisticsDetailedDto; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsRequestDto; import lv.ctco.springboottemplate.features.statistics.models.StatisticsSummaryDto; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api/statistics") @@ -27,25 +26,11 @@ public StatisticsController(StatisticsService statisticsService) { @GetMapping @Operation(summary = "Get todo statistics") - public ResponseEntity getStatistics( - @RequestParam(required = false) String from, - @RequestParam(required = false) String to, - @RequestParam(defaultValue = "summary") String format) { + public ResponseEntity getStatistics(@Valid @ModelAttribute StatisticsRequestDto input) { + LocalDate fromDate = input.fromDate(); + LocalDate toDate = input.toDate(); - LocalDate fromDate = parseDateOrNull(from, "from"); - LocalDate toDate = parseDateOrNull(to, "to"); - - if (!format.equalsIgnoreCase("summary") && !format.equalsIgnoreCase("detailed")) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "format must be either 'summary' or 'detailed'"); - } - - if (fromDate != null && toDate != null && fromDate.isAfter(toDate)) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "'from' date must be before or equal to 'to' date"); - } - - if (format.equalsIgnoreCase("summary")) { + if ("summary".equalsIgnoreCase(input.normalizedFormat())) { StatisticsSummaryDto summary = statisticsService.getSummary(fromDate, toDate); return ResponseEntity.ok(summary); } @@ -53,19 +38,4 @@ public ResponseEntity getStatistics( StatisticsDetailedDto detailed = statisticsService.getDetailed(fromDate, toDate); return ResponseEntity.ok(detailed); } - - private LocalDate parseDateOrNull(String raw, String paramName) { - if (raw == null || raw.isBlank()) { - return null; - } - - try { - return LocalDate.parse(raw); - } catch (DateTimeParseException ex) { - String message = - String.format("Invalid '%s' date. Expected format is yyyy-MM-dd.", paramName); - - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message, ex); - } - } } diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsRequestDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsRequestDto.java new file mode 100644 index 0000000..0513459 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsRequestDto.java @@ -0,0 +1,28 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import jakarta.validation.constraints.Pattern; +import java.time.LocalDate; +import lv.ctco.springboottemplate.validation.statistics.ValidStatisticsRequest; + +@ValidStatisticsRequest +public record StatisticsRequestDto( + String from, + String to, + @Pattern( + regexp = "^(summary|detailed)$", + flags = Pattern.Flag.CASE_INSENSITIVE, + message = "{statistics.format.invalid}") + String format) { + + public LocalDate fromDate() { + return from != null && !from.isBlank() ? LocalDate.parse(from) : null; + } + + public LocalDate toDate() { + return to != null && !to.isBlank() ? LocalDate.parse(to) : null; + } + + public String normalizedFormat() { + return format == null ? "summary" : format; + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/validation/statistics/ValidStatisticsRequest.java b/src/main/java/lv/ctco/springboottemplate/validation/statistics/ValidStatisticsRequest.java new file mode 100644 index 0000000..6ec4b03 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/validation/statistics/ValidStatisticsRequest.java @@ -0,0 +1,19 @@ +package lv.ctco.springboottemplate.validation.statistics; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = ValidStatisticsRequestValidator.class) +public @interface ValidStatisticsRequest { + String message() default "{statistics.request.invalid}"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/lv/ctco/springboottemplate/validation/statistics/ValidStatisticsRequestValidator.java b/src/main/java/lv/ctco/springboottemplate/validation/statistics/ValidStatisticsRequestValidator.java new file mode 100644 index 0000000..100078a --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/validation/statistics/ValidStatisticsRequestValidator.java @@ -0,0 +1,73 @@ +package lv.ctco.springboottemplate.validation.statistics; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsRequestDto; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; + +public class ValidStatisticsRequestValidator + implements ConstraintValidator { + + private final MessageSource messageSource; + + public ValidStatisticsRequestValidator(MessageSource messageSource) { + this.messageSource = messageSource; + } + + @Override + public boolean isValid(StatisticsRequestDto value, ConstraintValidatorContext context) { + if (value == null) { + return true; + } + + LocalDate fromDate = parseDate(value.from(), "from", context); + LocalDate toDate = parseDate(value.to(), "to", context); + + if (fromDate == null && hasValue(value.from())) { + return false; + } + if (toDate == null && hasValue(value.to())) { + return false; + } + + if (fromDate != null && toDate != null && fromDate.isAfter(toDate)) { + addViolation(context, message("statistics.range.invalid"), "from"); + return false; + } + + return true; + } + + private LocalDate parseDate(String raw, String field, ConstraintValidatorContext context) { + if (!hasValue(raw)) { + return null; + } + + try { + return LocalDate.parse(raw); + } catch (DateTimeParseException ex) { + addViolation(context, message("statistics.date.invalid", field), field); + return null; + } + } + + private void addViolation( + ConstraintValidatorContext context, String message, String propertyName) { + context.disableDefaultConstraintViolation(); + context + .buildConstraintViolationWithTemplate(message) + .addPropertyNode(propertyName) + .addConstraintViolation(); + } + + private boolean hasValue(String value) { + return value != null && !value.isBlank(); + } + + private String message(String code, Object... args) { + return messageSource.getMessage(code, args, LocaleContextHolder.getLocale()); + } +} diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties new file mode 100644 index 0000000..da235d7 --- /dev/null +++ b/src/main/resources/messages.properties @@ -0,0 +1,7 @@ +statistics.request.invalid=Invalid statistics request +statistics.format.invalid=format must be either 'summary' or 'detailed' +statistics.date.invalid=Invalid '{0}' date. Expected format is yyyy-MM-dd. +statistics.range.invalid='from' date must be before or equal to 'to' date +errors.request.failed=Request failed +errors.invalid.request.parameters=Invalid request parameters +errors.unexpected=Oops, something went wrong, try later