From aa918c384a0510955732da58afac01ba90e04330 Mon Sep 17 00:00:00 2001 From: Marcin Barc Date: Tue, 10 Nov 2026 14:12:38 +0100 Subject: [PATCH 1/2] refactor: flatten base package to com.authservice --- README.md | 4 ++-- .../authservice/{authservice => }/AuthServiceApplication.java | 2 +- .../{authservice => }/AuthServiceApplicationIT.java | 2 +- .../{authservice => }/TestcontainersConfiguration.java | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename src/main/java/com/authservice/{authservice => }/AuthServiceApplication.java (88%) rename src/test/java/com/authservice/{authservice => }/AuthServiceApplicationIT.java (90%) rename src/test/java/com/authservice/{authservice => }/TestcontainersConfiguration.java (93%) diff --git a/README.md b/README.md index 2c7f0ee..5db6b29 100644 --- a/README.md +++ b/README.md @@ -105,13 +105,13 @@ The application refuses to start without an active profile. In production set ``` src ├── main -│ ├── java/com/authservice/authservice # application source code +│ ├── java/com/authservice # application source code │ └── resources │ ├── application.properties # shared configuration │ ├── application-{dev,test,prod}.properties # profile-specific configuration │ └── db/changelog # Liquibase migrations └── test - └── java/com/authservice/authservice # unit (*Test) and integration (*IT) tests + └── java/com/authservice # unit (*Test) and integration (*IT) tests compose.yaml # local PostgreSQL .env.example # template for local .env ``` diff --git a/src/main/java/com/authservice/authservice/AuthServiceApplication.java b/src/main/java/com/authservice/AuthServiceApplication.java similarity index 88% rename from src/main/java/com/authservice/authservice/AuthServiceApplication.java rename to src/main/java/com/authservice/AuthServiceApplication.java index aef51ca..a3d88e3 100644 --- a/src/main/java/com/authservice/authservice/AuthServiceApplication.java +++ b/src/main/java/com/authservice/AuthServiceApplication.java @@ -1,4 +1,4 @@ -package com.authservice.authservice; +package com.authservice; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; diff --git a/src/test/java/com/authservice/authservice/AuthServiceApplicationIT.java b/src/test/java/com/authservice/AuthServiceApplicationIT.java similarity index 90% rename from src/test/java/com/authservice/authservice/AuthServiceApplicationIT.java rename to src/test/java/com/authservice/AuthServiceApplicationIT.java index 89338a5..a3c4638 100644 --- a/src/test/java/com/authservice/authservice/AuthServiceApplicationIT.java +++ b/src/test/java/com/authservice/AuthServiceApplicationIT.java @@ -1,4 +1,4 @@ -package com.authservice.authservice; +package com.authservice; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; diff --git a/src/test/java/com/authservice/authservice/TestcontainersConfiguration.java b/src/test/java/com/authservice/TestcontainersConfiguration.java similarity index 93% rename from src/test/java/com/authservice/authservice/TestcontainersConfiguration.java rename to src/test/java/com/authservice/TestcontainersConfiguration.java index abf6f5c..0b710c3 100644 --- a/src/test/java/com/authservice/authservice/TestcontainersConfiguration.java +++ b/src/test/java/com/authservice/TestcontainersConfiguration.java @@ -1,4 +1,4 @@ -package com.authservice.authservice; +package com.authservice; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; From f9a2d99c0e00cb5ed73eeb046e711124b2275396 Mon Sep 17 00:00:00 2001 From: Marcin Barc Date: Wed, 11 Nov 2026 11:17:53 +0100 Subject: [PATCH 2/2] feat: add global error handling with problem details --- .../common/error/BusinessException.java | 18 +++ .../authservice/common/error/ErrorCode.java | 19 ++++ .../common/error/FieldViolation.java | 4 + .../common/error/GlobalExceptionHandler.java | 106 ++++++++++++++++++ .../error/GlobalExceptionHandlerTest.java | 83 ++++++++++++++ 5 files changed, 230 insertions(+) create mode 100644 src/main/java/com/authservice/common/error/BusinessException.java create mode 100644 src/main/java/com/authservice/common/error/ErrorCode.java create mode 100644 src/main/java/com/authservice/common/error/FieldViolation.java create mode 100644 src/main/java/com/authservice/common/error/GlobalExceptionHandler.java create mode 100644 src/test/java/com/authservice/common/error/GlobalExceptionHandlerTest.java diff --git a/src/main/java/com/authservice/common/error/BusinessException.java b/src/main/java/com/authservice/common/error/BusinessException.java new file mode 100644 index 0000000..42f5dfa --- /dev/null +++ b/src/main/java/com/authservice/common/error/BusinessException.java @@ -0,0 +1,18 @@ +package com.authservice.common.error; + +import lombok.Getter; + +@Getter +public class BusinessException extends RuntimeException { + private final ErrorCode errorCode; + + public BusinessException(final ErrorCode errorCode) { + super(errorCode.getTitle()); + this.errorCode = errorCode; + } + + public BusinessException(final ErrorCode errorCode, final String detail) { + super(detail); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/authservice/common/error/ErrorCode.java b/src/main/java/com/authservice/common/error/ErrorCode.java new file mode 100644 index 0000000..a0c068c --- /dev/null +++ b/src/main/java/com/authservice/common/error/ErrorCode.java @@ -0,0 +1,19 @@ +package com.authservice.common.error; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + VALIDATION_FAILED(HttpStatus.BAD_REQUEST, "Validation failed"), + MALFORMED_REQUEST(HttpStatus.BAD_REQUEST, "Malformed request"), + RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "Resource not found"), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "Method not allowed"), + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"), + UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "Unsupported media type"); + + private final HttpStatus status; + private final String title; +} diff --git a/src/main/java/com/authservice/common/error/FieldViolation.java b/src/main/java/com/authservice/common/error/FieldViolation.java new file mode 100644 index 0000000..13748e6 --- /dev/null +++ b/src/main/java/com/authservice/common/error/FieldViolation.java @@ -0,0 +1,4 @@ +package com.authservice.common.error; + +public record FieldViolation(String field, String message) { +} diff --git a/src/main/java/com/authservice/common/error/GlobalExceptionHandler.java b/src/main/java/com/authservice/common/error/GlobalExceptionHandler.java new file mode 100644 index 0000000..3895efd --- /dev/null +++ b/src/main/java/com/authservice/common/error/GlobalExceptionHandler.java @@ -0,0 +1,106 @@ +package com.authservice.common.error; + +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.NonNull; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import java.util.List; +import java.util.Map; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + private static final String INTERNAL_ERROR_DETAIL = "An unexpected error occurred"; + private static final Map ERROR_CODES_BY_STATUS = Map.of( + HttpStatus.NOT_FOUND.value(), ErrorCode.RESOURCE_NOT_FOUND, + HttpStatus.METHOD_NOT_ALLOWED.value(), ErrorCode.METHOD_NOT_ALLOWED, + HttpStatus.UNSUPPORTED_MEDIA_TYPE.value(), ErrorCode.UNSUPPORTED_MEDIA_TYPE + ); + + private static boolean hasErrorCode(final ProblemDetail problem) { + return problem.getProperties() != null && problem.getProperties().containsKey("code"); + } + + private static ErrorCode defaultErrorCodeFor(final HttpStatusCode statusCode) { + final ErrorCode errorCode = ERROR_CODES_BY_STATUS.get(statusCode.value()); + if (errorCode != null) { + return errorCode; + } + return statusCode.is5xxServerError() ? ErrorCode.INTERNAL_ERROR : ErrorCode.MALFORMED_REQUEST; + } + + @ExceptionHandler(BusinessException.class) + ProblemDetail handleBusinessException(final BusinessException ex) { + log.debug("Business error: {} - {}", ex.getErrorCode(), ex.getMessage()); + return toProblemDetail(ex.getErrorCode(), ex.getMessage()); + } + + @ExceptionHandler(Exception.class) + ProblemDetail handleUnexpectedException(final Exception ex) { + log.error("Unexpected error", ex); + return toProblemDetail(ErrorCode.INTERNAL_ERROR, INTERNAL_ERROR_DETAIL); + } + + @Override + protected ResponseEntity handleExceptionInternal( + final @NonNull Exception ex, + final Object body, + final @NonNull HttpHeaders headers, + final @NonNull HttpStatusCode statusCode, + final @NonNull WebRequest request + ) { + final ResponseEntity response = super.handleExceptionInternal(ex, body, headers, statusCode, request); + if (response != null && response.getBody() instanceof final ProblemDetail problem && !hasErrorCode(problem)) { + final ErrorCode errorCode = defaultErrorCodeFor(statusCode); + problem.setTitle(errorCode.getTitle()); + problem.setProperty("code", errorCode.name()); + } + return response; + } + + @Override + protected ResponseEntity handleMethodArgumentNotValid( + final MethodArgumentNotValidException methodArgumentNotValidException, + final @NonNull HttpHeaders headers, + final @NonNull HttpStatusCode httpStatusCode, + final @NonNull WebRequest webRequest + ) { + final List violations = + methodArgumentNotValidException.getBindingResult() + .getFieldErrors() + .stream() + .map(error -> new FieldViolation(error.getField(), error.getDefaultMessage())) + .toList(); + + final ProblemDetail problem = toProblemDetail( + ErrorCode.VALIDATION_FAILED, + "Request contains invalid fields" + ); + + problem.setProperty("errors", violations); + + return handleExceptionInternal( + methodArgumentNotValidException, + problem, + headers, + httpStatusCode, + webRequest + ); + } + + private ProblemDetail toProblemDetail(final ErrorCode errorCode, final String detail) { + final ProblemDetail problem = ProblemDetail.forStatusAndDetail(errorCode.getStatus(), detail); + problem.setTitle(errorCode.getTitle()); + problem.setProperty("code", errorCode.name()); + return problem; + } +} diff --git a/src/test/java/com/authservice/common/error/GlobalExceptionHandlerTest.java b/src/test/java/com/authservice/common/error/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..3548797 --- /dev/null +++ b/src/test/java/com/authservice/common/error/GlobalExceptionHandlerTest.java @@ -0,0 +1,83 @@ +package com.authservice.common.error; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class GlobalExceptionHandlerTest { + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new ThrowingController()) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + void shouldMapBusinessExceptionToProblemDetail() throws Exception { + mockMvc.perform(get("/test/business")) + .andExpect(status().isNotFound()) + .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")) + .andExpect(jsonPath("$.title").value("Resource not found")) + .andExpect(jsonPath("$.detail").value("Custom detail")) + .andExpect(jsonPath("$.instance").value("/test/business")); + } + + @Test + void shouldHideDetailsOfUnexpectedException() throws Exception { + mockMvc.perform(get("/test/unexpected")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.code").value("INTERNAL_ERROR")) + .andExpect(jsonPath("$.detail").value("An unexpected error occurred")) + .andExpect(content().string(not(containsString("secret")))); + } + + @Test + void shouldReturnFieldErrorsForInvalidRequest() throws Exception { + mockMvc.perform(post("/test/validated") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\": \"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")) + .andExpect(jsonPath("$.errors[0].field").value("name")) + .andExpect(jsonPath("$.errors[0].message").value("must not be blank")); + } + + @RestController + static class ThrowingController { + @GetMapping("/test/business") + void throwBusinessException() { + throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "Custom detail"); + } + + @GetMapping("/test/unexpected") + void throwUnexpectedException() { + throw new IllegalStateException("Connection failed, secret password: admin123"); + } + + @PostMapping("/test/validated") + void acceptValidBody(@Valid @RequestBody final TestRequest request) { + } + } + + record TestRequest(@NotBlank String name) { + } +} \ No newline at end of file