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.common;

import jakarta.validation.ConstraintViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

@ControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<String> handleConstraintViolationException(
ConstraintViolationException ex) {
return new ResponseEntity<>(ex.getMessage(), HttpStatus.BAD_REQUEST);
}

@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<String> handleMethodArgumentTypeMismatchException(
MethodArgumentTypeMismatchException ex) {
return new ResponseEntity<>(ex.getMessage(), HttpStatus.BAD_REQUEST);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package lv.ctco.springboottemplate.features.statistics;

import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
import java.util.Map;
import java.util.Optional;

@JsonInclude(JsonInclude.Include.NON_ABSENT)
public record Statistics(
int totalTodos,
int completedTodos,
int pendingTodos,
Map<String, Integer> userStats,
Optional<Map<String, List<StatisticsTodo>>> todos) {}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unusual formatting. If you use IDEA you can press Ctrl+Llt+L or check "Reformat code" in commit options.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spotless check on build fails otherwise, maybe it's supposed to look like this

Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package lv.ctco.springboottemplate.features.statistics;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.time.LocalDate;
import java.util.Optional;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
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;

@RestController
@RequestMapping("/api/statistics")
@Tag(name = "Statistics Controller", description = "Statistics endpoint")
@Validated
public class StatisticsController {
private final StatisticsService statisticsService;

public StatisticsController(StatisticsService statisticsService) {
this.statisticsService = statisticsService;
}

@GetMapping
@Operation(summary = "Get todo statistics")
public ResponseEntity<Statistics> getStatistics(
@Parameter(description = "Start date. Format: YYYY-MM-DD", example = "2023-01-01")
@RequestParam
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
Optional<LocalDate> from,
@Parameter(description = "End date. Format: YYYY-MM-DD", example = "2023-12-31")
@RequestParam
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
Optional<LocalDate> to,
@Parameter(description = "Response format", example = "summary")
@Schema(allowableValues = {"summary", "detailed"})
@RequestParam
@StatisticsFormat
String format) {
var statisticsFormat =
"summary".equals(format) ? StatisticsFormats.SUMMARY : StatisticsFormats.DETAILED;
return ResponseEntity.ok(statisticsService.getStatistics(from, to, statisticsFormat));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package lv.ctco.springboottemplate.features.statistics;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;

@Documented
@Constraint(validatedBy = StatisticsFormatValidator.class)
@Target({ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface StatisticsFormat {
String message() default "Either 'summary' or 'detailed' must be provided for format";

Class<?>[] groups() default {};

Class<? extends Payload>[] payload() default {};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package lv.ctco.springboottemplate.features.statistics;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.springframework.stereotype.Component;

@Component
public class StatisticsFormatValidator implements ConstraintValidator<StatisticsFormat, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
return "summary".equals(value) || "detailed".equals(value);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package lv.ctco.springboottemplate.features.statistics;

public enum StatisticsFormats {
SUMMARY,
DETAILED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package lv.ctco.springboottemplate.features.statistics;

import static org.springframework.data.mongodb.core.aggregation.Aggregation.*;
import static org.springframework.data.mongodb.core.query.Criteria.where;

import java.time.LocalDate;
import java.time.LocalTime;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
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.Service;

@Service
public class StatisticsService {
private final MongoTemplate mongoTemplate;

public StatisticsService(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}

public Statistics getStatistics(
Optional<LocalDate> from, Optional<LocalDate> to, StatisticsFormats format) {
boolean isSummary = StatisticsFormats.SUMMARY.equals(format);
Document result =
isSummary
? getSummaryStatisticsAggregation(from, to).getUniqueMappedResult()
: getDetailedStatisticsAggregation(from, to).getUniqueMappedResult();

int totalTodos = getTodoCount(result, "totalTodos");
int completedTodos = getTodoCount(result, "completedTodos");
int pendingTodos = getTodoCount(result, "pendingTodos");

Map<String, Integer> userStats = getUserStats(result);

if (isSummary) {
return new Statistics(totalTodos, completedTodos, pendingTodos, userStats, Optional.empty());
}

Map<String, List<StatisticsTodo>> todos = getStatisticsTodos(result);

return new Statistics(totalTodos, completedTodos, pendingTodos, userStats, Optional.of(todos));
}

private AggregationResults<Document> getDetailedStatisticsAggregation(
Optional<LocalDate> from, Optional<LocalDate> to) {
ProjectionOperation projectPendingFields = project("id", "title", "createdBy", "createdAt");
ProjectionOperation projectCompletedFields =
projectPendingFields.andExpression("updatedAt").as("completedAt");

FacetOperation detailsFacet =
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here I would split this method into two, for summary and detailed data to be returned. If I understood correctly, based on format type we return only one of the facetOperation results, mething that those initializations can be done and hidden WITHIN an IF or in a separate methods

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

facet(countTodos())
.as("totalTodos")
.and(matchTodosByCompletedState(true), countTodos())
.as("completedTodos")
.and(matchTodosByCompletedState(false), countTodos())
.as("pendingTodos")
.and(countTodosPerUser())
.as("userStats")
.and(matchTodosByCompletedState(false), projectPendingFields)
.as("pending")
.and(matchTodosByCompletedState(true), projectCompletedFields)
.as("completed");

Aggregation detailedAggregation = newAggregation(matchDateRange(from, to), detailsFacet);

return this.mongoTemplate.aggregate(detailedAggregation, "todos", Document.class);
}

private AggregationResults<Document> getSummaryStatisticsAggregation(
Optional<LocalDate> from, Optional<LocalDate> to) {
FacetOperation summaryFacet =
facet(countTodos())
.as("totalTodos")
.and(matchTodosByCompletedState(true), countTodos())
.as("completedTodos")
.and(matchTodosByCompletedState(false), countTodos())
.as("pendingTodos")
.and(countTodosPerUser())
.as("userStats");

Aggregation summaryAggregation = newAggregation(matchDateRange(from, to), summaryFacet);

return this.mongoTemplate.aggregate(summaryAggregation, "todos", Document.class);
}

private MatchOperation matchDateRange(Optional<LocalDate> from, Optional<LocalDate> to) {
Criteria criteria =
Stream.of(
from.map(f -> where("createdAt").gte(f.atStartOfDay())),
to.map(t -> where("createdAt").lte(t.atTime(LocalTime.MAX))))
.flatMap(Optional::stream)
.reduce(
new Criteria(),
(c1, c2) ->
c1.getCriteriaObject().isEmpty() ? c2 : new Criteria().andOperator(c1, c2));
return Aggregation.match(criteria);
}

private MatchOperation matchTodosByCompletedState(boolean completed) {
return match(where("completed").is(completed));
}

private GroupOperation countTodos() {
return group().count().as("count");
}

private GroupOperation countTodosPerUser() {
return group("createdBy").count().as("count");
}

private int getTodoCount(Document result, String key) {
List<Document> totalTodosDocument = result.getList(key, Document.class);
if (totalTodosDocument.isEmpty()) {
return 0;
}
return (int) totalTodosDocument.getFirst().get("count");
}

private Map<String, Integer> getUserStats(Document result) {
List<Document> userStatsDocument = result.getList("userStats", Document.class);
return userStatsDocument.stream()
.collect(
Collectors.toMap(
stat -> (String) stat.get("_id"), stat -> (Integer) stat.get("count")));
}

private Map<String, List<StatisticsTodo>> getStatisticsTodos(Document result) {
List<Document> pendingDocument = result.getList("pending", Document.class);
List<StatisticsTodo> pending =
pendingDocument.stream()
.map(
item ->
new StatisticsTodo(
item.get("_id").toString(),
item.get("title").toString(),
item.get("createdBy").toString(),
((Date) item.get("createdAt")).toInstant(),
Optional.empty()))
.toList();

List<Document> completedDocument = result.getList("completed", Document.class);
List<StatisticsTodo> completed =
completedDocument.stream()
.map(
item ->
new StatisticsTodo(
item.get("_id").toString(),
item.get("title").toString(),
item.get("createdBy").toString(),
((Date) item.get("createdAt")).toInstant(),
Optional.ofNullable(((Date) item.get("completedAt")).toInstant())))
.toList();
Map<String, List<StatisticsTodo>> todos = new HashMap<>();
todos.put("completed", completed);
todos.put("pending", pending);
return todos;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package lv.ctco.springboottemplate.features.statistics;

import java.time.Instant;
import java.util.Optional;
import org.springframework.data.annotation.Id;

public record StatisticsTodo(
@Id String id,
String title,
String createdBy,
Instant createdAt,
Optional<Instant> completedAt) {}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ void only_validly_named_top_level_classes_should_exist_in_features_package() {
.haveSimpleNameStartingWith("Todo")
.orShould()
.haveSimpleNameStartingWith("Greeting")
.orShould()
.haveSimpleNameStartingWith("Statistics")
.because(
"""
The 'features' package should contain only top-level components like:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ public void check(JavaClass item, ConditionEvents events) {
item.getMethods().stream()
.filter(method -> !isSpringLifecycleMethod(method))
.filter(method -> method.getAccessesToSelf().isEmpty())
// added to fix test, there may be a better way to handle this though
.filter(method -> !isValidatorIsValidMethod(item, method))
.forEach(
method ->
events.add(
Expand All @@ -59,6 +61,13 @@ private boolean isSpringLifecycleMethod(JavaMethod method) {
|| method.getName().equals("afterPropertiesSet")
|| method.getName().startsWith("set");
}

private boolean isValidatorIsValidMethod(JavaClass classItem, JavaMethod method) {
return method.getName().equals("isValid")
&& classItem.getAllRawInterfaces().stream()
.anyMatch(
i -> i.getName().equals("jakarta.validation.ConstraintValidator"));
}
})
.because(
"All methods in Spring beans must be used somewhere in the codebase. "
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ void non_beans_should_not_have_unused_non_private_static_methods() {
.areNotAnnotatedWith(Repository.class)
.and()
.areNotAnnotatedWith(RestController.class)
// Enum.valueOf is static and public so it fails the test
.and()
.areNotAssignableTo(Enum.class)
.should(
new ArchCondition<>("have all non-private static methods used") {
@Override
Expand Down
Loading