-
Notifications
You must be signed in to change notification settings - Fork 1
Kirill topchy ctco task/2 #46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: KirillTopchyCtco
Are you sure you want to change the base?
Changes from all commits
c2988aa
1a715bf
7c62fbe
7480ad8
e0336cb
1fdc6c5
4405ac8
037749d
e3b4e60
3c3b8b3
01f9209
750da21
0f48ed8
7e1f418
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| package lv.ctco.springboottemplate.features.greeting; | ||
|
|
||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/api/greeting") | ||
| @Tag(name = "Greeting Controller", description = "Greeting related endpoints") | ||
| public class GreetingController { | ||
| private final GreetingService greetingService; | ||
|
|
||
| public GreetingController(GreetingService greetingService) { | ||
| this.greetingService = greetingService; | ||
| } | ||
|
|
||
| @GetMapping | ||
| @Operation(summary = "Get greeting message") | ||
| public String getGreeting() { | ||
| return greetingService.greet(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package lv.ctco.springboottemplate.features.greeting; | ||
|
|
||
| import lv.ctco.springboottemplate.features.todo.TodoService; | ||
| import org.springframework.stereotype.Service; | ||
|
|
||
| @Service | ||
| public class GreetingService { | ||
| private final TodoService todoService; | ||
|
|
||
| public GreetingService(TodoService todoService) { | ||
| this.todoService = todoService; | ||
| } | ||
|
|
||
| public String greet() { | ||
| long activeTodosCount = | ||
| todoService.getAllTodos().stream().filter(todo -> !todo.completed()).count(); | ||
|
|
||
| return String.format("Hello from Spring! You have %d open tasks.", activeTodosCount); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| package lv.ctco.springboottemplate.features.statistics; | ||
|
|
||
| import org.bson.Document; | ||
|
|
||
| import java.util.LinkedHashMap; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
|
|
||
| public final class StatisticsAggregationUtil { | ||
| public static Map<String, Integer> extractUserStats(Document root) { | ||
| if (root == null) return Map.of(); | ||
| List<Document> userStatsDocs = getArray(root, "userStats"); | ||
| Map<String, Integer> map = new LinkedHashMap<>(); | ||
| for (Document d : userStatsDocs) { | ||
| String user = d.getString("_id"); | ||
| int count = d.getInteger("count", 0); | ||
| map.put(user, count); | ||
| } | ||
| return map; | ||
| } | ||
|
|
||
| public static List<Document> getArray(Document doc, String key) { | ||
| if (doc == null) return List.of(); | ||
| Object val = doc.get(key); | ||
| if (val instanceof List<?> list) { | ||
| return list.stream().filter(Document.class::isInstance).map(Document.class::cast).toList(); | ||
| } | ||
| return List.of(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| package lv.ctco.springboottemplate.features.statistics; | ||
|
|
||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.media.Content; | ||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
| import io.swagger.v3.oas.annotations.responses.ApiResponse; | ||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||
| import lv.ctco.springboottemplate.features.statistics.models.StatisticsErrorResponse; | ||
| import lv.ctco.springboottemplate.features.statistics.models.StatisticsRequest; | ||
| import lv.ctco.springboottemplate.features.statistics.models.StatisticsResponse; | ||
| import lv.ctco.springboottemplate.features.statistics.services.StatisticsService; | ||
| 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.RestController; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/api/statistics") | ||
| @Tag(name = "Todo Statistics Controller", description = "Todo statistics related endpoints") | ||
| public class StatisticsController { | ||
| private final StatisticsService statisticsService; | ||
|
|
||
| public StatisticsController(StatisticsService statisticsService) { | ||
| this.statisticsService = statisticsService; | ||
| } | ||
|
|
||
| @GetMapping | ||
| @Operation( | ||
| summary = "Get todo statistics", | ||
| description = "Returns summary or detailed todo statistics. Optional date filters are applied to createdAt field.", | ||
| responses = { | ||
| @ApiResponse(responseCode = "200", description = "Statistics computed successfully"), | ||
| @ApiResponse(responseCode = "400", description = "Invalid parameters", content = @Content(schema = @Schema(implementation = StatisticsErrorResponse.class))), | ||
| @ApiResponse(responseCode = "500", description = "Internal server error", content = @Content(schema = @Schema(implementation = StatisticsErrorResponse.class))) | ||
| } | ||
| ) | ||
| public ResponseEntity<?> getStatistics(StatisticsRequest request) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why "?"? Is response class unknown? |
||
| StatisticsResponse response = statisticsService.computeStatistics(request); | ||
| return ResponseEntity.ok(response); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| package lv.ctco.springboottemplate.features.statistics; | ||
|
|
||
| import lv.ctco.springboottemplate.features.statistics.models.StatisticsErrorResponse; | ||
| import org.slf4j.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
| import org.springframework.http.HttpStatus; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.web.bind.annotation.ExceptionHandler; | ||
| import org.springframework.web.bind.annotation.RestControllerAdvice; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @RestControllerAdvice(assignableTypes = StatisticsController.class) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not global exception handler but only for statistic controller? I see that you have specific return type for this, but it is better to come up with universal error class structure, which will be used by all controllers |
||
| public class StatisticsExceptionHandler { | ||
|
|
||
| private static final Logger log = LoggerFactory.getLogger(StatisticsExceptionHandler.class); | ||
|
|
||
| @ExceptionHandler(IllegalArgumentException.class) | ||
| public ResponseEntity<StatisticsErrorResponse> handleIllegalArgument(IllegalArgumentException ex) { | ||
| return ResponseEntity.badRequest() | ||
| .body(new StatisticsErrorResponse(List.of(ex.getMessage()))); | ||
| } | ||
|
|
||
| @ExceptionHandler(RuntimeException.class) | ||
| public ResponseEntity<StatisticsErrorResponse> handleRuntime(RuntimeException ex) { | ||
| log.error("Unhandled runtime exception in StatisticsController", ex); | ||
| return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) | ||
| .body(new StatisticsErrorResponse(List.of("Internal server error"))); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| package lv.ctco.springboottemplate.features.statistics; | ||
|
|
||
| import lv.ctco.springboottemplate.features.statistics.models.StatisticsFormat; | ||
| import lv.ctco.springboottemplate.features.statistics.models.StatisticsQuery; | ||
| import lv.ctco.springboottemplate.features.statistics.models.StatisticsRequest; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.time.format.DateTimeParseException; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
|
|
||
| @Component | ||
| public class StatisticsQueryBuilder { | ||
| public StatisticsQuery build(StatisticsRequest request) { | ||
| List<String> errors = new ArrayList<>(); | ||
| LocalDate from = parseDate("from", request.from(), errors); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As an option you might use validation framework and that use @Valid in controller |
||
| LocalDate to = parseDate("to", request.to(), errors); | ||
|
|
||
| if (from != null && to != null && from.isAfter(to)) { | ||
| errors.add("Parameter 'from' must be before or equal to 'to'."); | ||
| } | ||
|
|
||
| if (!errors.isEmpty()) { | ||
| throw new IllegalArgumentException(String.join("; ", errors)); | ||
| } | ||
|
|
||
| StatisticsFormat format = StatisticsQuery.parseFormat(request.format()); | ||
| return new StatisticsQuery(from, to, format); | ||
| } | ||
|
|
||
| private LocalDate parseDate(String name, String raw, List<String> errors) { | ||
| if (raw == null || raw.isBlank()) { | ||
| return null; | ||
| } | ||
| try { | ||
| return LocalDate.parse(raw); | ||
| } catch (DateTimeParseException e) { | ||
| errors.add("Invalid '" + name + "' date: " + raw); | ||
| return null; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package lv.ctco.springboottemplate.features.statistics.models; | ||
|
|
||
| import java.util.Map; | ||
|
|
||
| public record StatisticsDetailedResponse( | ||
| int totalTodos, | ||
| int completedTodos, | ||
| int pendingTodos, | ||
| Map<String, Integer> userStats, | ||
| TodosSection todos | ||
| ) implements StatisticsResponse { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| package lv.ctco.springboottemplate.features.statistics.models; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public record StatisticsErrorResponse(List<String> errors) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| package lv.ctco.springboottemplate.features.statistics.models; | ||
|
|
||
| public enum StatisticsFormat { | ||
| SUMMARY, | ||
| DETAILED | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| package lv.ctco.springboottemplate.features.statistics.models; | ||
|
|
||
| import java.time.LocalDate; | ||
|
|
||
| public record StatisticsQuery( | ||
| LocalDate from, | ||
| LocalDate to, | ||
| StatisticsFormat format | ||
| ) { | ||
|
|
||
| public StatisticsQuery { | ||
| if (from != null && to != null && from.isAfter(to)) { | ||
| throw new IllegalArgumentException("Parameter 'from' must be before or equal to 'to'."); | ||
| } | ||
| } | ||
|
|
||
| public static StatisticsFormat parseFormat(String value) { | ||
| if (value == null) { | ||
| return StatisticsFormat.SUMMARY; | ||
| } | ||
|
|
||
| if (value.trim().equalsIgnoreCase("detailed")) { | ||
| return StatisticsFormat.DETAILED; | ||
| } | ||
|
|
||
| return StatisticsFormat.SUMMARY; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package lv.ctco.springboottemplate.features.statistics.models; | ||
|
|
||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
|
|
||
| public record StatisticsRequest( | ||
| @Schema(description = "Start date (inclusive), format: yyyy-MM-dd", example = "2023-01-01") | ||
| String from, | ||
| @Schema(description = "End date (inclusive), format: yyyy-MM-dd", example = "2023-12-31") | ||
| String to, | ||
| @Schema(description = "Response format: summary | detailed (default: summary)", example = "summary") | ||
| String format | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| package lv.ctco.springboottemplate.features.statistics.models; | ||
|
|
||
| public interface StatisticsResponse { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package lv.ctco.springboottemplate.features.statistics.models; | ||
|
|
||
| import java.util.Map; | ||
|
|
||
| public record StatisticsSummaryResponse( | ||
| int totalTodos, | ||
| int completedTodos, | ||
| int pendingTodos, | ||
| Map<String, Integer> userStats | ||
| ) implements StatisticsResponse { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| package lv.ctco.springboottemplate.features.statistics.models; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public record TodosSection(List<TodosStatistics> completed, List<TodosStatistics> pending) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package lv.ctco.springboottemplate.features.statistics.models; | ||
|
|
||
| import java.time.Instant; | ||
| import java.util.Optional; | ||
|
|
||
| public record TodosStatistics( | ||
| String id, | ||
| String title, | ||
| String createdBy, | ||
| String createdAt, | ||
| Optional<Instant> completedAt) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| package lv.ctco.springboottemplate.features.statistics.services; | ||
|
|
||
| import lv.ctco.springboottemplate.features.statistics.models.StatisticsQuery; | ||
| import org.bson.Document; | ||
| import org.springframework.data.mongodb.core.MongoTemplate; | ||
| import org.springframework.data.mongodb.core.aggregation.*; | ||
| import org.springframework.data.mongodb.core.query.Criteria; | ||
| import org.springframework.stereotype.Repository; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.time.ZoneOffset; | ||
|
|
||
| @Repository | ||
| public class StatisticsRepository { | ||
|
|
||
| private final MongoTemplate mongoTemplate; | ||
|
|
||
| public StatisticsRepository(MongoTemplate mongoTemplate) { | ||
| this.mongoTemplate = mongoTemplate; | ||
| } | ||
|
|
||
| public Document executeSummary(StatisticsQuery query) { | ||
| Aggregation agg = Aggregation.newAggregation( | ||
| matchDateFilter(query), | ||
| Aggregation.facet( | ||
| Aggregation.group() | ||
| .count().as("total") | ||
| .sum(ConditionalOperators.when(Criteria.where("completed").is(true)).then(1).otherwise(0)).as("completed") | ||
| ).as("counts") | ||
| .and(Aggregation.group("createdBy").count().as("count")).as("userStats") | ||
| ); | ||
| return aggregate(agg); | ||
| } | ||
|
|
||
| public Document executeDetailed(StatisticsQuery query) { | ||
| ProjectionOperation projectTodosFields = Aggregation.project("title", "createdBy", "createdAt", "updatedAt", "completed", "completedAt"); | ||
| Aggregation agg = Aggregation.newAggregation( | ||
| matchDateFilter(query), | ||
| Aggregation.facet( | ||
| Aggregation.group() | ||
| .count().as("total") | ||
| .sum(ConditionalOperators.when(Criteria.where("completed").is(true)).then(1).otherwise(0)).as("completed") | ||
| ).as("counts") | ||
| .and(Aggregation.group("createdBy").count().as("count")).as("userStats") | ||
| .and(Aggregation.match(Criteria.where("completed").is(true)), projectTodosFields).as("completedTodosSource") | ||
| .and(Aggregation.match(Criteria.where("completed").is(false)), projectTodosFields).as("pendingTodosSource") | ||
| ); | ||
| return aggregate(agg); | ||
| } | ||
|
|
||
| private Document aggregate(Aggregation aggregation) { | ||
| AggregationResults<Document> results = mongoTemplate.aggregate(aggregation, "todos", Document.class); | ||
| return results.getUniqueMappedResult(); | ||
| } | ||
|
|
||
| private MatchOperation matchDateFilter(StatisticsQuery query) { | ||
| LocalDate from = query.from(); | ||
| LocalDate to = query.to(); | ||
| if (from == null && to == null) { | ||
| return Aggregation.match(new Criteria()); | ||
| } | ||
| Criteria criteria = Criteria.where("createdAt"); | ||
| if (from != null && to != null) { | ||
| criteria.gte(from.atStartOfDay().toInstant(ZoneOffset.UTC)) | ||
| .lte(to.plusDays(1).atStartOfDay().toInstant(ZoneOffset.UTC).minusMillis(1)); | ||
| } else if (from != null) { | ||
| criteria.gte(from.atStartOfDay().toInstant(ZoneOffset.UTC)); | ||
| } else { | ||
| criteria.lte(to.plusDays(1).atStartOfDay().toInstant(ZoneOffset.UTC).minusMillis(1)); | ||
| } | ||
| return Aggregation.match(criteria); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is better to create map using stream on userStatsDocs