Skip to content
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

Feature/input validation - Close #6 #24

Merged
merged 3 commits into from
May 17, 2024
Merged
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
4 changes: 4 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<!-- Swagger -->
<dependency>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.seyed.ali.timeentryservice.annotation;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.*;

/**
* Annotation to mark fields as optional for validation purposes.
* Fields annotated with {@code @OptionalField} are considered optional,
* allowing users to omit these fields in their input without triggering validation errors.
* <p>
* This annotation should be used in conjunction with a custom validator to enforce the optional behavior.
* </p>
*
* @author [Seyed Ali]
* @since 0.1
*/
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = OptionalFieldValidator.class)
public @interface OptionalField {

/**
* Returns the error message template.
*
* @return the error message template
*/
String message() default "Field is optional";

/**
* Returns the groups the constraint belongs to.
*
* @return the groups the constraint belongs to
*/
Class<?>[] groups() default {};

/**
* Returns the payload associated with the constraint.
*
* @return the payload associated with the constraint
*/
Class<? extends Payload>[] payload() default {};

}

Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.seyed.ali.timeentryservice.annotation;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

/**
* Custom validator for the {@link OptionalField} annotation.
* <p>
* This validator checks whether a field annotated with {@code @OptionalField} is optional or not.
* The field is considered optional if its value is null or an empty string ("").
* </p>
*
* @author [Seyed Ali]
* @since 0.1
*/
public class OptionalFieldValidator implements ConstraintValidator<OptionalField, Object> {

/**
* Checks whether the given value is valid.
* The value is considered valid if it is null or an empty string ("").
*
* @param value The value to validate
* @param context The constraint validator context
* @return {@code true}, indicating that the field is optional and considered valid.
* Otherwise, it returns {@code false}, indicating that the field has a value and should be validated according to other constraints
*/
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
return value == null || (value instanceof String && ((String) value).isEmpty());
}

}

Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
Expand Down Expand Up @@ -52,7 +53,7 @@ public ResponseEntity<Result> getUsersTimeEntry(@PathVariable String userId) {
}

@PostMapping
public ResponseEntity<Result> addTimeEntryManually(@RequestBody TimeEntryDTO timeEntryDTO) {
public ResponseEntity<Result> addTimeEntryManually(@Valid @RequestBody TimeEntryDTO timeEntryDTO) {
return ResponseEntity.status(CREATED).body(new Result(
true,
CREATED,
Expand All @@ -62,7 +63,7 @@ public ResponseEntity<Result> addTimeEntryManually(@RequestBody TimeEntryDTO tim
}

@PutMapping("/{timeEntryId}")
public ResponseEntity<Result> updateTimeEntryManually(@PathVariable String timeEntryId, @RequestBody TimeEntryDTO timeEntryDTO) {
public ResponseEntity<Result> updateTimeEntryManually(@Valid @PathVariable String timeEntryId, @RequestBody TimeEntryDTO timeEntryDTO) {
return ResponseEntity.ok(new Result(
true,
OK,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.seyed.ali.timeentryservice.model.dto.response.Result;
import com.seyed.ali.timeentryservice.service.interfaces.TimeEntryTrackingService;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
Expand All @@ -22,9 +23,14 @@ public class TimeEntryTrackingController {
private final TimeEntryTrackingService timeEntryService;

@PostMapping("/start")
public ResponseEntity<Result> startTrackingTimeEntry(@RequestBody TimeBillingDTO timeBillingDTO) {
boolean billable = timeBillingDTO.isBillable();
BigDecimal hourlyRate = timeBillingDTO.getHourlyRate();
public ResponseEntity<Result> startTrackingTimeEntry(@Valid @RequestBody TimeBillingDTO timeBillingDTO) {
boolean billable = false;
BigDecimal hourlyRate = BigDecimal.ZERO;

if (timeBillingDTO != null) {
billable = timeBillingDTO.billable();
hourlyRate = timeBillingDTO.hourlyRate();
}
return ResponseEntity.status(CREATED).body(new Result(
true,
CREATED,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
package com.seyed.ali.timeentryservice.exceptions.handler;

import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.seyed.ali.timeentryservice.exceptions.OperationNotSupportedException;
import com.seyed.ali.timeentryservice.exceptions.ResourceNotFoundException;
import com.seyed.ali.timeentryservice.model.dto.response.Result;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.NOT_FOUND;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static org.springframework.http.HttpStatus.*;

@RestControllerAdvice
public class TimeEntryServiceHandlerAdvice {
Expand Down Expand Up @@ -44,4 +53,41 @@ public ResponseEntity<Result> handleOperationNotSupportedException(OperationNotS
));
}

@ExceptionHandler({MethodArgumentNotValidException.class})
public ResponseEntity<Result> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
List<ObjectError> allErrors = e.getBindingResult().getAllErrors();
Map<String, String> map = new HashMap<>(allErrors.size());
allErrors.forEach(objectError -> {
String defaultMessage = objectError.getDefaultMessage();
String field = ((FieldError) objectError).getField();
map.put(field, defaultMessage);
});

return ResponseEntity.status(BAD_REQUEST).body(new Result(
false,
BAD_REQUEST,
"Provided arguments are invalid, see data for details.",
map
));
}

@ExceptionHandler({HttpMessageNotReadableException.class})
public ResponseEntity<Result> handleHttpMessageNotReadableException(HttpMessageNotReadableException e) {
String errorMessage = "Invalid request format. Please check your request and try again.";
Throwable cause = e.getCause();

if (cause instanceof JsonParseException jsonParseException) {
errorMessage = "JSON parse error: " + jsonParseException.getOriginalMessage();
} else if (cause instanceof JsonMappingException jsonMappingException) {
errorMessage = "JSON mapping error at " + jsonMappingException.getPathReference() + ": " + jsonMappingException.getOriginalMessage();
}

return ResponseEntity.status(BAD_REQUEST).body(new Result(
false,
BAD_REQUEST,
errorMessage,
e.getMessage()
));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public class TimeEntry {
@Builder.Default
private boolean billable = false;
@Builder.Default
private BigDecimal hourlyRate = BigDecimal.TEN;
private BigDecimal hourlyRate = BigDecimal.ZERO;

@OneToMany(mappedBy = "timeEntry", cascade = CascadeType.ALL)
@ToString.Exclude
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
package com.seyed.ali.timeentryservice.model.dto;

import lombok.AllArgsConstructor;
import com.seyed.ali.timeentryservice.model.domain.TimeEntry;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;

@Data
/**
* DTO for {@link TimeEntry}
*/
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TimeBillingDTO {

private boolean billable;
private BigDecimal hourlyRate;
public record TimeBillingDTO(
@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = "A flag determining this time entry is billable", example = "true")
boolean billable,

@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = "The hourly rate in BigDecimal format", example = "10.0")
@DecimalMin(value = "0.0", inclusive = false, message = "hourlyRate must be greater than 0")
@DecimalMax(value = "999.99", message = "hourlyRate must be less than 1000")
BigDecimal hourlyRate
) {
}
Original file line number Diff line number Diff line change
@@ -1,25 +1,39 @@
package com.seyed.ali.timeentryservice.model.dto;

import com.seyed.ali.timeentryservice.annotation.OptionalField;
import com.seyed.ali.timeentryservice.model.domain.TimeEntry;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.*;

/**
* DTO for {@link TimeEntry}
*/
@Schema(description = "Time Entry Data Transfer Object")
public record TimeEntryDTO(
@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = "Unique identifier for the time entry", example = "12345")
@Size(max = 36, message = "timeEntryId must be maximum 36 characters")
String timeEntryId,

@Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Start time of the time entry in the format yyyy-MM-dd HH:mm", example = "2024-05-12 08:00:00")
@NotBlank(message = "startTime is mandatory") @Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$", message = "startTime must be in the format yyyy-MM-dd HH:mm:ss")
String startTime,

@Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "End time of the time entry in the format yyyy-MM-dd HH:mm", example = "2024-05-12 10:00:00")
@NotBlank(message = "endTime is mandatory")
@Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$", message = "endTime must be in the format yyyy-MM-dd HH:mm:ss")
String endTime,

@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = "A flag determining this time entry is billable", example = "true")
boolean billable,

@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = "The hourly rate in BigDecimal format", example = "10.0")
@DecimalMin(value = "0.0", inclusive = false, message = "hourlyRate must be greater than 0")
@DecimalMax(value = "999.99", message = "hourlyRate must be less than 1000")
@OptionalField
String hourlyRate,

@Schema(requiredMode = Schema.RequiredMode.AUTO, description = "Duration of the time entry in the format HH:mm:ss", example = "02:00:00")
@Pattern(regexp = "^\\d{2}:\\d{2}:\\d{2}$", message = "duration must be in the format HH:mm:ss")
String duration
) {
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
package com.seyed.ali.timeentryservice.model.dto;

import com.seyed.ali.timeentryservice.model.domain.TimeSegment;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;

import java.io.Serializable;

/**
* DTO for {@link com.seyed.ali.timeentryservice.model.domain.TimeSegment}
* DTO for {@link TimeSegment}
*/
@Schema
@Builder
Expand Down

This file was deleted.

Loading