Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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<>();
Copy link
Collaborator

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

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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The 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)
Copy link
Collaborator

Choose a reason for hiding this comment

The 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);
Copy link
Collaborator

Choose a reason for hiding this comment

The 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);
}
}

Loading
Loading