Skip to content

Commit

Permalink
Merge pull request #24 from sayedxali/feature/input-validation
Browse files Browse the repository at this point in the history
Feature/input validation - Close #6
  • Loading branch information
seyedali-dev committed May 17, 2024
2 parents 21e165e + a3d44a1 commit 25da9dd
Show file tree
Hide file tree
Showing 15 changed files with 200 additions and 119 deletions.
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

0 comments on commit 25da9dd

Please sign in to comment.