diff --git a/.gitignore b/.gitignore index ac695fa..f8c0405 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ ### IntelliJ IDEA ### .idea/ + +### Logs ### +backend/**/logs/* +!backend/**/logs/.gitkeep +.idea \ No newline at end of file diff --git a/backend/.idea/workspace.xml b/backend/.idea/workspace.xml deleted file mode 100644 index 8bb6e82..0000000 --- a/backend/.idea/workspace.xml +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - 1783800962578 - - - - - - \ No newline at end of file diff --git a/backend/appointment-service/pom.xml b/backend/appointment-service/pom.xml index d3667a2..0c573cb 100644 --- a/backend/appointment-service/pom.xml +++ b/backend/appointment-service/pom.xml @@ -105,6 +105,12 @@ io.confluent kafka-avro-serializer 7.5.1 + + + io.swagger.core.v3 + swagger-annotations + + com.google.guava diff --git a/backend/appointment-service/src/main/java/com/appointment/adapters/in/controller/AppointmentController.java b/backend/appointment-service/src/main/java/com/appointment/adapters/in/controller/AppointmentController.java index 039381b..d0f3b2b 100644 --- a/backend/appointment-service/src/main/java/com/appointment/adapters/in/controller/AppointmentController.java +++ b/backend/appointment-service/src/main/java/com/appointment/adapters/in/controller/AppointmentController.java @@ -10,6 +10,7 @@ import com.appointment.usecases.ports.in.FindAppointmentByIdUseCase; import com.appointment.usecases.ports.in.ListAppointmentsUseCase; import com.appointment.usecases.ports.in.UpdateAppointmentStatusUseCase; +import io.swagger.v3.oas.annotations.Parameter; import jakarta.validation.Valid; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -59,6 +60,7 @@ public AppointmentController( @PostMapping("/appointment") public ResponseEntity>> create( + @Parameter(example = "11111111-1111-1111-1111-111111111111") @RequestHeader("Idempotency-Key") String idempotencyKey, @Valid @RequestBody AppointmentRequest request ) { @@ -72,7 +74,7 @@ public ResponseEntity>>> @RequestParam(required = false) Status status, @PageableDefault(size = 10, sort = "scheduledAt") Pageable pageable ) { - Page page = listAppointmentsUseCase.execute(status, pageable); + Page page = listAppointmentsUseCase.execute(status, AppointmentSortSanitizer.sanitize(pageable)); PagedModel> pagedModel = pagedResourcesAssembler.toModel(page, appointmentModelAssembler::toModel); @@ -91,7 +93,7 @@ public ResponseEntity>> updateStatu @PathVariable UUID id, @Valid @RequestBody UpdateAppointmentStatusRequest request ) { - Appointment updated = updateAppointmentStatusUseCase.execute(id, request.status(), request.observation()); + Appointment updated = updateAppointmentStatusUseCase.execute(id, request.toStatus(), request.observation()); return ResponseEntity.ok(ApiResponse.of(appointmentModelAssembler.toModel(updated), "Appointment status updated successfully")); } diff --git a/backend/appointment-service/src/main/java/com/appointment/adapters/in/controller/AppointmentSortSanitizer.java b/backend/appointment-service/src/main/java/com/appointment/adapters/in/controller/AppointmentSortSanitizer.java new file mode 100644 index 0000000..2f2e478 --- /dev/null +++ b/backend/appointment-service/src/main/java/com/appointment/adapters/in/controller/AppointmentSortSanitizer.java @@ -0,0 +1,29 @@ +package com.appointment.adapters.in.controller; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.util.Set; + +final class AppointmentSortSanitizer { + + private static final Set SORTABLE_PROPERTIES = + Set.of("scheduledAt", "status", "patientName", "createdAt", "updatedAt"); + + private static final Sort DEFAULT_SORT = Sort.by(Sort.Direction.ASC, "scheduledAt"); + + private AppointmentSortSanitizer() { + } + + static Pageable sanitize(Pageable pageable) { + Sort validOrders = Sort.by(pageable.getSort().stream() + .filter(order -> SORTABLE_PROPERTIES.contains(order.getProperty())) + .toList()); + + Sort sort = validOrders.isSorted() ? validOrders : DEFAULT_SORT; + return pageable.getSort().equals(sort) + ? pageable + : PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort); + } +} diff --git a/backend/appointment-service/src/main/java/com/appointment/adapters/in/controller/dtos/AppointmentRequest.java b/backend/appointment-service/src/main/java/com/appointment/adapters/in/controller/dtos/AppointmentRequest.java index 9c00b11..1de407f 100644 --- a/backend/appointment-service/src/main/java/com/appointment/adapters/in/controller/dtos/AppointmentRequest.java +++ b/backend/appointment-service/src/main/java/com/appointment/adapters/in/controller/dtos/AppointmentRequest.java @@ -2,6 +2,7 @@ import com.appointment.entities.Appointment; import com.appointment.enums.Status; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Future; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -13,13 +14,16 @@ public record AppointmentRequest( @NotNull @CPF(message = "${response.error-messages.invalid-cpf}") + @Schema(example = "529.982.247-25") String patientCpf, @NotBlank + @Schema(example = "John Doe") String patientName, @NotNull @Future(message = "${response.error-messages.appointment-with-invalid-date}") + @Schema(example = "2030-01-01T10:00:00") LocalDateTime scheduledAt ) { public Appointment toEntity(String idempotencyKey) { diff --git a/backend/appointment-service/src/main/java/com/appointment/adapters/in/controller/dtos/UpdateAppointmentStatusRequest.java b/backend/appointment-service/src/main/java/com/appointment/adapters/in/controller/dtos/UpdateAppointmentStatusRequest.java index 6b4b100..a0c8ed5 100644 --- a/backend/appointment-service/src/main/java/com/appointment/adapters/in/controller/dtos/UpdateAppointmentStatusRequest.java +++ b/backend/appointment-service/src/main/java/com/appointment/adapters/in/controller/dtos/UpdateAppointmentStatusRequest.java @@ -1,10 +1,16 @@ package com.appointment.adapters.in.controller.dtos; import com.appointment.enums.Status; +import com.appointment.frameworks.validation.ValidEnum; import jakarta.validation.constraints.NotNull; public record UpdateAppointmentStatusRequest( - @NotNull Status status, + @NotNull + @ValidEnum(enumClass = Status.class) + String status, String observation ) { + public Status toStatus() { + return Status.valueOf(status); + } } diff --git a/backend/appointment-service/src/main/java/com/appointment/frameworks/config/ValidationMessageConfig.java b/backend/appointment-service/src/main/java/com/appointment/frameworks/config/ValidationMessageConfig.java new file mode 100644 index 0000000..e950576 --- /dev/null +++ b/backend/appointment-service/src/main/java/com/appointment/frameworks/config/ValidationMessageConfig.java @@ -0,0 +1,59 @@ +package com.appointment.frameworks.config; + +import jakarta.validation.MessageInterpolator; +import org.hibernate.validator.messageinterpolation.ParameterMessageInterpolator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Configuration +public class ValidationMessageConfig { + + @Bean + public LocalValidatorFactoryBean localValidatorFactoryBean(Environment environment) { + LocalValidatorFactoryBean factoryBean = new LocalValidatorFactoryBean(); + factoryBean.setMessageInterpolator( + new PropertyPlaceholderMessageInterpolator(new ParameterMessageInterpolator(), environment) + ); + return factoryBean; + } + + private static class PropertyPlaceholderMessageInterpolator implements MessageInterpolator { + + private static final Pattern PLACEHOLDER = Pattern.compile("\\$\\{([^}]+)}"); + + private final MessageInterpolator delegate; + private final Environment environment; + + PropertyPlaceholderMessageInterpolator(MessageInterpolator delegate, Environment environment) { + this.delegate = delegate; + this.environment = environment; + } + + @Override + public String interpolate(String messageTemplate, Context context) { + return delegate.interpolate(resolve(messageTemplate), context); + } + + @Override + public String interpolate(String messageTemplate, Context context, Locale locale) { + return delegate.interpolate(resolve(messageTemplate), context, locale); + } + + private String resolve(String messageTemplate) { + Matcher matcher = PLACEHOLDER.matcher(messageTemplate); + StringBuilder result = new StringBuilder(); + while (matcher.find()) { + String value = environment.getProperty(matcher.group(1)); + matcher.appendReplacement(result, Matcher.quoteReplacement(value != null ? value : matcher.group(0))); + } + matcher.appendTail(result); + return result.toString(); + } + } +} diff --git a/backend/appointment-service/src/main/java/com/appointment/frameworks/exceptions/GlobalExceptionHandler.java b/backend/appointment-service/src/main/java/com/appointment/frameworks/exceptions/GlobalExceptionHandler.java index 8ed2188..cda7b43 100644 --- a/backend/appointment-service/src/main/java/com/appointment/frameworks/exceptions/GlobalExceptionHandler.java +++ b/backend/appointment-service/src/main/java/com/appointment/frameworks/exceptions/GlobalExceptionHandler.java @@ -8,11 +8,13 @@ import org.springframework.dao.DataAccessException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.resource.NoResourceFoundException; import java.util.LinkedHashMap; @@ -70,6 +72,18 @@ public ResponseEntity handleNoResourceFound(NoResourceFoundException ex) { return ResponseEntity.notFound().build(); } + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity> handleMethodArgumentTypeMismatch(MethodArgumentTypeMismatchException ex) { + ApiResponse body = ApiResponse.of(null, "Invalid value for parameter: " + ex.getName()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleHttpMessageNotReadable(HttpMessageNotReadableException ex) { + ApiResponse body = ApiResponse.of(null, "Malformed or invalid request body."); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + @ExceptionHandler(DataAccessException.class) public ResponseEntity> handleDataAccessException(DataAccessException ex) { log.error("Database error", ex); diff --git a/backend/appointment-service/src/main/java/com/appointment/frameworks/validation/EnumValidator.java b/backend/appointment-service/src/main/java/com/appointment/frameworks/validation/EnumValidator.java new file mode 100644 index 0000000..f08cb0b --- /dev/null +++ b/backend/appointment-service/src/main/java/com/appointment/frameworks/validation/EnumValidator.java @@ -0,0 +1,39 @@ +package com.appointment.frameworks.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.util.Arrays; +import java.util.stream.Collectors; + +public class EnumValidator implements ConstraintValidator { + + private Class> enumClass; + + @Override + public void initialize(ValidEnum annotation) { + this.enumClass = annotation.enumClass(); + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) { + return true; + } + + boolean valid = Arrays.stream(enumClass.getEnumConstants()) + .anyMatch(candidate -> candidate.name().equals(value)); + + if (!valid) { + String allowedValues = Arrays.stream(enumClass.getEnumConstants()) + .map(Enum::name) + .collect(Collectors.joining(", ")); + + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate("must be one of: " + allowedValues) + .addConstraintViolation(); + } + + return valid; + } +} diff --git a/backend/appointment-service/src/main/java/com/appointment/frameworks/validation/ValidEnum.java b/backend/appointment-service/src/main/java/com/appointment/frameworks/validation/ValidEnum.java new file mode 100644 index 0000000..da119e4 --- /dev/null +++ b/backend/appointment-service/src/main/java/com/appointment/frameworks/validation/ValidEnum.java @@ -0,0 +1,23 @@ +package com.appointment.frameworks.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = EnumValidator.class) +public @interface ValidEnum { + + Class> enumClass(); + + String message() default "must be one of the accepted values"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/backend/appointment-service/src/test/java/com/appointment/adapters/in/controller/AppointmentControllerIntegrationTest.java b/backend/appointment-service/src/test/java/com/appointment/adapters/in/controller/AppointmentControllerIntegrationTest.java index faeeef0..144de9a 100644 --- a/backend/appointment-service/src/test/java/com/appointment/adapters/in/controller/AppointmentControllerIntegrationTest.java +++ b/backend/appointment-service/src/test/java/com/appointment/adapters/in/controller/AppointmentControllerIntegrationTest.java @@ -249,7 +249,7 @@ void shouldUpdateStatusFromPendingToConfirmed() throws Exception { mockMvc.perform(patch("/api/v1/appointments/{id}/status", id) .contentType("application/json") - .content(objectMapper.writeValueAsString(new UpdateAppointmentStatusRequest(Status.CONFIRMED, null)))) + .content(objectMapper.writeValueAsString(new UpdateAppointmentStatusRequest(Status.CONFIRMED.name(), null)))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").value("CONFIRMED")) .andExpect(jsonPath("$.data.links[?(@.rel=='cancel')]").exists()) @@ -262,7 +262,7 @@ void shouldUpdateStatusToCanceledWithObservation() throws Exception { mockMvc.perform(patch("/api/v1/appointments/{id}/status", id) .contentType("application/json") - .content(objectMapper.writeValueAsString(new UpdateAppointmentStatusRequest(Status.CANCELED, "patient requested cancellation")))) + .content(objectMapper.writeValueAsString(new UpdateAppointmentStatusRequest(Status.CANCELED.name(), "patient requested cancellation")))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").value("CANCELED")) .andExpect(jsonPath("$.data.observation").value("patient requested cancellation")) @@ -276,7 +276,7 @@ void shouldReturnConflictWhenCancelingWithoutObservation() throws Exception { mockMvc.perform(patch("/api/v1/appointments/{id}/status", id) .contentType("application/json") - .content(objectMapper.writeValueAsString(new UpdateAppointmentStatusRequest(Status.CANCELED, null)))) + .content(objectMapper.writeValueAsString(new UpdateAppointmentStatusRequest(Status.CANCELED.name(), null)))) .andExpect(status().isConflict()); } @@ -286,15 +286,26 @@ void shouldReturnConflictWhenUpdatingAlreadyCanceledAppointment() throws Excepti mockMvc.perform(patch("/api/v1/appointments/{id}/status", id) .contentType("application/json") - .content(objectMapper.writeValueAsString(new UpdateAppointmentStatusRequest(Status.CONFIRMED, null)))) + .content(objectMapper.writeValueAsString(new UpdateAppointmentStatusRequest(Status.CONFIRMED.name(), null)))) .andExpect(status().isConflict()); } + @Test + void shouldReturnBadRequestForStatusOutsideEnum() throws Exception { + UUID id = persist(Status.PENDING, 1).id(); + + mockMvc.perform(patch("/api/v1/appointments/{id}/status", id) + .contentType("application/json") + .content(objectMapper.writeValueAsString(new UpdateAppointmentStatusRequest("FOOBAR", null)))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.data.status").value("must be one of: PENDING, CONFIRMED, CANCELED")); + } + @Test void shouldReturnNotFoundWhenUpdatingStatusOfUnknownId() throws Exception { mockMvc.perform(patch("/api/v1/appointments/{id}/status", UUID.randomUUID()) .contentType("application/json") - .content(objectMapper.writeValueAsString(new UpdateAppointmentStatusRequest(Status.CONFIRMED, null)))) + .content(objectMapper.writeValueAsString(new UpdateAppointmentStatusRequest(Status.CONFIRMED.name(), null)))) .andExpect(status().isNotFound()); } @@ -304,7 +315,7 @@ void shouldRepublishToQueueWhenUpdatingStatusBackToPending() throws Exception { mockMvc.perform(patch("/api/v1/appointments/{id}/status", id) .contentType("application/json") - .content(objectMapper.writeValueAsString(new UpdateAppointmentStatusRequest(Status.PENDING, null)))) + .content(objectMapper.writeValueAsString(new UpdateAppointmentStatusRequest(Status.PENDING.name(), null)))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").value("PENDING")); diff --git a/backend/appointment-service/src/test/java/com/appointment/adapters/in/controller/AppointmentSortSanitizerTest.java b/backend/appointment-service/src/test/java/com/appointment/adapters/in/controller/AppointmentSortSanitizerTest.java new file mode 100644 index 0000000..2525bc8 --- /dev/null +++ b/backend/appointment-service/src/test/java/com/appointment/adapters/in/controller/AppointmentSortSanitizerTest.java @@ -0,0 +1,49 @@ +package com.appointment.adapters.in.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import static org.assertj.core.api.Assertions.assertThat; + +class AppointmentSortSanitizerTest { + + @Test + void shouldFallBackToDefaultSortWhenPropertyIsNotSortable() { + Pageable pageable = PageRequest.of(0, 10, Sort.by("string")); + + Pageable sanitized = AppointmentSortSanitizer.sanitize(pageable); + + assertThat(sanitized.getSort()).isEqualTo(Sort.by(Sort.Direction.ASC, "scheduledAt")); + } + + @Test + void shouldKeepValidSortProperty() { + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "patientName")); + + Pageable sanitized = AppointmentSortSanitizer.sanitize(pageable); + + assertThat(sanitized.getSort()).isEqualTo(Sort.by(Sort.Direction.DESC, "patientName")); + } + + @Test + void shouldDropOnlyInvalidPropertiesAndKeepValidOnesWhenMixed() { + Sort mixedSort = Sort.by(Sort.Order.asc("status")).and(Sort.by(Sort.Order.desc("string"))); + Pageable pageable = PageRequest.of(0, 10, mixedSort); + + Pageable sanitized = AppointmentSortSanitizer.sanitize(pageable); + + assertThat(sanitized.getSort()).isEqualTo(Sort.by(Sort.Direction.ASC, "status")); + } + + @Test + void shouldPreservePageNumberAndSize() { + Pageable pageable = PageRequest.of(2, 5, Sort.by("string")); + + Pageable sanitized = AppointmentSortSanitizer.sanitize(pageable); + + assertThat(sanitized.getPageNumber()).isEqualTo(2); + assertThat(sanitized.getPageSize()).isEqualTo(5); + } +} diff --git a/backend/appointment-service/src/test/java/com/appointment/frameworks/exceptions/GlobalExceptionHandlerTest.java b/backend/appointment-service/src/test/java/com/appointment/frameworks/exceptions/GlobalExceptionHandlerTest.java index a81102a..f276579 100644 --- a/backend/appointment-service/src/test/java/com/appointment/frameworks/exceptions/GlobalExceptionHandlerTest.java +++ b/backend/appointment-service/src/test/java/com/appointment/frameworks/exceptions/GlobalExceptionHandlerTest.java @@ -5,14 +5,19 @@ import jakarta.validation.ConstraintViolationException; import jakarta.validation.Path; import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; import org.springframework.dao.QueryTimeoutException; +import org.springframework.http.HttpInputMessage; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.resource.NoResourceFoundException; import java.util.Map; import java.util.Set; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -60,6 +65,34 @@ void shouldReturnBadRequestForConstraintViolation() { assertThat(response.getBody().data()).containsEntry("scheduledAt", "must be in the future"); } + @Test + void shouldReturnBadRequestForMethodArgumentTypeMismatch() { + MethodParameter parameter = mock(MethodParameter.class); + MethodArgumentTypeMismatchException ex = + new MethodArgumentTypeMismatchException("string", UUID.class, "id", parameter, null); + + ResponseEntity> response = handler.handleMethodArgumentTypeMismatch(ex); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data()).isNull(); + assertThat(response.getBody().message()).isEqualTo("Invalid value for parameter: id"); + } + + @Test + void shouldReturnBadRequestForHttpMessageNotReadable() { + HttpInputMessage inputMessage = mock(HttpInputMessage.class); + HttpMessageNotReadableException ex = + new HttpMessageNotReadableException("Cannot deserialize value of type `Status`", inputMessage); + + ResponseEntity> response = handler.handleHttpMessageNotReadable(ex); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data()).isNull(); + assertThat(response.getBody().message()).isEqualTo("Malformed or invalid request body."); + } + @Test void shouldReturnNotFoundForUnmappedStaticResource() { ResponseEntity response = handler.handleNoResourceFound(new NoResourceFoundException(HttpMethod.GET, "/unknown", "/unknown"));