Exception handling #82

Merged
stne3960 merged 6 commits from feature/exception-handling into main 2026-02-02 11:18:37 +01:00
5 changed files with 156 additions and 143 deletions
Showing only changes of commit 9e63eb6406 - Show all commits

View File

@ -8,26 +8,26 @@ import org.springframework.http.HttpStatus;
*/ */
public class ForbiddenException extends StudentportalenException { public class ForbiddenException extends StudentportalenException {
public ForbiddenException(String message) { public ForbiddenException(String message) {
super(message); super(message);
} }
public ForbiddenException(String message, Throwable cause) { public ForbiddenException(String message, Throwable cause) {
super(message, cause); super(message, cause);
} }
@Override @Override
public String getType() { public String getType() {
return "studentportalen:forbidden"; return "studentportalen:forbidden";
} }
@Override @Override
public String getTitle() { public String getTitle() {
return "Access denied"; return "Access denied";
} }
@Override @Override
public HttpStatus getStatus() { public HttpStatus getStatus() {
return HttpStatus.FORBIDDEN; return HttpStatus.FORBIDDEN;
} }
} }

View File

@ -1,5 +1,9 @@
package se.su.dsv.studentportalen.bff.exception; package se.su.dsv.studentportalen.bff.exception;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -9,11 +13,6 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/** /**
* Centralized exception handler for all REST controllers. * Centralized exception handler for all REST controllers.
* Returns RFC 7807 Problem Details for all error responses. * Returns RFC 7807 Problem Details for all error responses.
@ -21,65 +20,77 @@ import java.util.stream.Collectors;
@RestControllerAdvice @RestControllerAdvice
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
private static final Logger LOG = LoggerFactory.getLogger(GlobalExceptionHandler.class); private static final Logger LOG = LoggerFactory.getLogger(
GlobalExceptionHandler.class
);
/** /**
* Handles Jakarta Bean Validation errors (missing/invalid fields). * Handles Jakarta Bean Validation errors (missing/invalid fields).
* Triggered when @Valid fails on request body. * Triggered when @Valid fails on request body.
*/ */
@ExceptionHandler(MethodArgumentNotValidException.class) @ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) { public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
Map<String, List<String>> violations = ex.getBindingResult().getFieldErrors().stream() Map<String, List<String>> violations = ex
.collect(Collectors.groupingBy( .getBindingResult()
FieldError::getField, .getFieldErrors()
Collectors.mapping( .stream()
error -> error.getDefaultMessage() != null ? error.getDefaultMessage() : "Invalid value", .collect(
Collectors.toList() Collectors.groupingBy(
) FieldError::getField,
)); Collectors.mapping(
error ->
error.getDefaultMessage() != null
? error.getDefaultMessage()
: "Invalid value",
Collectors.toList()
)
)
);
LOG.warn("Validation failed: {}", violations); LOG.warn("Validation failed: {}", violations);
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problem.setType(URI.create("studentportalen:validation-error")); problem.setType(URI.create("studentportalen:validation-error"));
problem.setTitle("Validation failed"); problem.setTitle("Validation failed");
problem.setDetail("One or more fields are invalid"); problem.setDetail("One or more fields are invalid");
problem.setProperty("violations", violations); problem.setProperty("violations", violations);
return problem; return problem;
}
/**
* Handles business exceptions from the BFF.
* These include mapped errors from backend systems like Daisy.
*/
@ExceptionHandler(StudentportalenException.class)
public ProblemDetail handleBusinessError(StudentportalenException ex) {
LOG.warn("Business error: {} - {}", ex.getType(), ex.getMessage());
ProblemDetail problem = ProblemDetail.forStatus(ex.getStatus());
problem.setType(URI.create(ex.getType()));
problem.setTitle(ex.getTitle());
problem.setDetail(ex.getMessage());
if (ex instanceof ValidationException ve && !ve.getViolations().isEmpty()) {
problem.setProperty("violations", ve.getViolations());
} }
/** return problem;
* Handles business exceptions from the BFF. }
* These include mapped errors from backend systems like Daisy.
*/
@ExceptionHandler(StudentportalenException.class)
public ProblemDetail handleBusinessError(StudentportalenException ex) {
LOG.warn("Business error: {} - {}", ex.getType(), ex.getMessage());
ProblemDetail problem = ProblemDetail.forStatus(ex.getStatus()); /**
problem.setType(URI.create(ex.getType())); * Handles unexpected errors.
problem.setTitle(ex.getTitle()); * Logs the full stack trace but returns a generic message to the client.
problem.setDetail(ex.getMessage()); */
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex) {
LOG.error("Unexpected error", ex);
if (ex instanceof ValidationException ve && !ve.getViolations().isEmpty()) { ProblemDetail problem = ProblemDetail.forStatus(
problem.setProperty("violations", ve.getViolations()); HttpStatus.INTERNAL_SERVER_ERROR
} );
problem.setType(URI.create("studentportalen:internal-error"));
return problem; problem.setTitle("An unexpected error occurred");
} problem.setDetail("Please try again later");
return problem;
/** }
* Handles unexpected errors.
* Logs the full stack trace but returns a generic message to the client.
*/
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex) {
LOG.error("Unexpected error", ex);
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
problem.setType(URI.create("studentportalen:internal-error"));
problem.setTitle("An unexpected error occurred");
problem.setDetail("Please try again later");
return problem;
}
} }

View File

@ -8,26 +8,26 @@ import org.springframework.http.HttpStatus;
*/ */
public class ServiceUnavailableException extends StudentportalenException { public class ServiceUnavailableException extends StudentportalenException {
public ServiceUnavailableException(String message) { public ServiceUnavailableException(String message) {
super(message); super(message);
} }
public ServiceUnavailableException(String message, Throwable cause) { public ServiceUnavailableException(String message, Throwable cause) {
super(message, cause); super(message, cause);
} }
@Override @Override
public String getType() { public String getType() {
return "studentportalen:service-unavailable"; return "studentportalen:service-unavailable";
} }
@Override @Override
public String getTitle() { public String getTitle() {
return "Service unavailable"; return "Service unavailable";
} }
@Override @Override
public HttpStatus getStatus() { public HttpStatus getStatus() {
return HttpStatus.SERVICE_UNAVAILABLE; return HttpStatus.SERVICE_UNAVAILABLE;
} }
} }

View File

@ -8,27 +8,27 @@ import org.springframework.http.HttpStatus;
*/ */
public abstract class StudentportalenException extends RuntimeException { public abstract class StudentportalenException extends RuntimeException {
protected StudentportalenException(String message) { protected StudentportalenException(String message) {
super(message); super(message);
} }
protected StudentportalenException(String message, Throwable cause) { protected StudentportalenException(String message, Throwable cause) {
super(message, cause); super(message, cause);
} }
/** /**
* The error type URI for RFC 7807 Problem Details. * The error type URI for RFC 7807 Problem Details.
* Example: "studentportalen:validation-error" * Example: "studentportalen:validation-error"
*/ */
public abstract String getType(); public abstract String getType();
/** /**
* A short, human-readable summary of the problem type. * A short, human-readable summary of the problem type.
*/ */
public abstract String getTitle(); public abstract String getTitle();
/** /**
* The HTTP status code for this error. * The HTTP status code for this error.
*/ */
public abstract HttpStatus getStatus(); public abstract HttpStatus getStatus();
} }

View File

@ -1,9 +1,8 @@
package se.su.dsv.studentportalen.bff.exception; package se.su.dsv.studentportalen.bff.exception;
import org.springframework.http.HttpStatus;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import org.springframework.http.HttpStatus;
/** /**
* Exception for validation errors, including field-level violations. * Exception for validation errors, including field-level violations.
@ -11,37 +10,40 @@ import java.util.Map;
*/ */
public class ValidationException extends StudentportalenException { public class ValidationException extends StudentportalenException {
private final Map<String, List<String>> violations; private final Map<String, List<String>> violations;
public ValidationException(String message) { public ValidationException(String message) {
this(message, Map.of()); this(message, Map.of());
} }
public ValidationException(String message, Map<String, List<String>> violations) { public ValidationException(
super(message); String message,
this.violations = violations != null ? Map.copyOf(violations) : Map.of(); Map<String, List<String>> violations
} ) {
super(message);
this.violations = violations != null ? Map.copyOf(violations) : Map.of();
}
@Override @Override
public String getType() { public String getType() {
return "studentportalen:validation-error"; return "studentportalen:validation-error";
} }
@Override @Override
public String getTitle() { public String getTitle() {
return "Validation failed"; return "Validation failed";
} }
@Override @Override
public HttpStatus getStatus() { public HttpStatus getStatus() {
return HttpStatus.BAD_REQUEST; return HttpStatus.BAD_REQUEST;
} }
/** /**
* Field-level validation errors. * Field-level validation errors.
* Keys are field names, values are lists of error messages. * Keys are field names, values are lists of error messages.
*/ */
public Map<String, List<String>> getViolations() { public Map<String, List<String>> getViolations() {
return violations; return violations;
} }
} }