Skip to content
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: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.authservice.authservice;
package com.authservice;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
Expand Down
18 changes: 18 additions & 0 deletions src/main/java/com/authservice/common/error/BusinessException.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
19 changes: 19 additions & 0 deletions src/main/java/com/authservice/common/error/ErrorCode.java
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.authservice.common.error;

public record FieldViolation(String field, String message) {
}
106 changes: 106 additions & 0 deletions src/main/java/com/authservice/common/error/GlobalExceptionHandler.java
Original file line number Diff line number Diff line change
@@ -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<Integer, ErrorCode> 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<Object> handleExceptionInternal(
final @NonNull Exception ex,
final Object body,
final @NonNull HttpHeaders headers,
final @NonNull HttpStatusCode statusCode,
final @NonNull WebRequest request
) {
final ResponseEntity<Object> 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<Object> handleMethodArgumentNotValid(
final MethodArgumentNotValidException methodArgumentNotValidException,
final @NonNull HttpHeaders headers,
final @NonNull HttpStatusCode httpStatusCode,
final @NonNull WebRequest webRequest
) {
final List<FieldViolation> 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;
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.authservice.authservice;
package com.authservice;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {
}
}
Loading