diff --git a/bff/pom.xml b/bff/pom.xml index cfd48dd..32b3a1d 100644 --- a/bff/pom.xml +++ b/bff/pom.xml @@ -32,6 +32,10 @@ org.springframework.boot spring-boot-starter-oauth2-client + + org.springframework.boot + spring-boot-starter-validation + diff --git a/bff/src/main/java/se/su/dsv/studentportalen/bff/exception/ForbiddenException.java b/bff/src/main/java/se/su/dsv/studentportalen/bff/exception/ForbiddenException.java new file mode 100644 index 0000000..ada8cde --- /dev/null +++ b/bff/src/main/java/se/su/dsv/studentportalen/bff/exception/ForbiddenException.java @@ -0,0 +1,33 @@ +package se.su.dsv.studentportalen.bff.exception; + +import org.springframework.http.HttpStatus; + +/** + * Exception for authorization failures. + * Thrown when the user is authenticated but not allowed to perform the action. + */ +public class ForbiddenException extends StudentportalenException { + + public ForbiddenException(String message) { + super(message); + } + + public ForbiddenException(String message, Throwable cause) { + super(message, cause); + } + + @Override + public String getType() { + return "studentportalen:forbidden"; + } + + @Override + public String getTitle() { + return "Access denied"; + } + + @Override + public HttpStatus getStatus() { + return HttpStatus.FORBIDDEN; + } +} diff --git a/bff/src/main/java/se/su/dsv/studentportalen/bff/exception/GlobalExceptionHandler.java b/bff/src/main/java/se/su/dsv/studentportalen/bff/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..f54ddd9 --- /dev/null +++ b/bff/src/main/java/se/su/dsv/studentportalen/bff/exception/GlobalExceptionHandler.java @@ -0,0 +1,96 @@ +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.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Centralized exception handler for all REST controllers. + * Returns RFC 7807 Problem Details for all error responses. + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger LOG = LoggerFactory.getLogger( + GlobalExceptionHandler.class + ); + + /** + * Handles Jakarta Bean Validation errors (missing/invalid fields). + * Triggered when @Valid fails on request body. + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ProblemDetail handleValidation(MethodArgumentNotValidException ex) { + Map> violations = ex + .getBindingResult() + .getFieldErrors() + .stream() + .collect( + Collectors.groupingBy( + FieldError::getField, + Collectors.mapping( + error -> + error.getDefaultMessage() != null + ? error.getDefaultMessage() + : "Invalid value", + Collectors.toList() + ) + ) + ); + + LOG.warn("Validation failed: {}", violations); + + ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); + problem.setType(URI.create("studentportalen:validation-error")); + problem.setTitle("Validation failed"); + problem.setDetail("One or more fields are invalid"); + problem.setProperty("violations", violations); + 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 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; + } +} diff --git a/bff/src/main/java/se/su/dsv/studentportalen/bff/exception/ServiceUnavailableException.java b/bff/src/main/java/se/su/dsv/studentportalen/bff/exception/ServiceUnavailableException.java new file mode 100644 index 0000000..d160d1e --- /dev/null +++ b/bff/src/main/java/se/su/dsv/studentportalen/bff/exception/ServiceUnavailableException.java @@ -0,0 +1,33 @@ +package se.su.dsv.studentportalen.bff.exception; + +import org.springframework.http.HttpStatus; + +/** + * Exception for backend service unavailability. + * Thrown when a backend system (e.g., Daisy) is unreachable or returns 5xx errors. + */ +public class ServiceUnavailableException extends StudentportalenException { + + public ServiceUnavailableException(String message) { + super(message); + } + + public ServiceUnavailableException(String message, Throwable cause) { + super(message, cause); + } + + @Override + public String getType() { + return "studentportalen:service-unavailable"; + } + + @Override + public String getTitle() { + return "Service unavailable"; + } + + @Override + public HttpStatus getStatus() { + return HttpStatus.SERVICE_UNAVAILABLE; + } +} diff --git a/bff/src/main/java/se/su/dsv/studentportalen/bff/exception/StudentportalenException.java b/bff/src/main/java/se/su/dsv/studentportalen/bff/exception/StudentportalenException.java new file mode 100644 index 0000000..193f2a0 --- /dev/null +++ b/bff/src/main/java/se/su/dsv/studentportalen/bff/exception/StudentportalenException.java @@ -0,0 +1,34 @@ +package se.su.dsv.studentportalen.bff.exception; + +import org.springframework.http.HttpStatus; + +/** + * Base exception for all Studentportalen BFF errors. + * Subclasses define specific error types that map to RFC 7807 Problem Details. + */ +public abstract class StudentportalenException extends RuntimeException { + + protected StudentportalenException(String message) { + super(message); + } + + protected StudentportalenException(String message, Throwable cause) { + super(message, cause); + } + + /** + * The error type URI for RFC 7807 Problem Details. + * Example: "studentportalen:validation-error" + */ + public abstract String getType(); + + /** + * A short, human-readable summary of the problem type. + */ + public abstract String getTitle(); + + /** + * The HTTP status code for this error. + */ + public abstract HttpStatus getStatus(); +} diff --git a/bff/src/main/java/se/su/dsv/studentportalen/bff/exception/ValidationException.java b/bff/src/main/java/se/su/dsv/studentportalen/bff/exception/ValidationException.java new file mode 100644 index 0000000..1d69220 --- /dev/null +++ b/bff/src/main/java/se/su/dsv/studentportalen/bff/exception/ValidationException.java @@ -0,0 +1,49 @@ +package se.su.dsv.studentportalen.bff.exception; + +import java.util.List; +import java.util.Map; +import org.springframework.http.HttpStatus; + +/** + * Exception for validation errors, including field-level violations. + * Used for both BFF validation failures and mapped Daisy validation errors. + */ +public class ValidationException extends StudentportalenException { + + private final Map> violations; + + public ValidationException(String message) { + this(message, Map.of()); + } + + public ValidationException( + String message, + Map> violations + ) { + super(message); + this.violations = violations != null ? Map.copyOf(violations) : Map.of(); + } + + @Override + public String getType() { + return "studentportalen:validation-error"; + } + + @Override + public String getTitle() { + return "Validation failed"; + } + + @Override + public HttpStatus getStatus() { + return HttpStatus.BAD_REQUEST; + } + + /** + * Field-level validation errors. + * Keys are field names, values are lists of error messages. + */ + public Map> getViolations() { + return violations; + } +}