Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
407 changes: 407 additions & 0 deletions backend/src/asciidoc/index.adoc

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ public class AppException extends RuntimeException {
*/
private final HttpStatus status;

/**
* Optional arguments for resolving i18n messages.
*/
private final Object[] messageArgs;

/**
* Constructs a new AppException with the specified message and status.
* This constructor:
Expand All @@ -32,8 +37,20 @@ public class AppException extends RuntimeException {
* @param status The HTTP status code to be returned in the response
*/
public AppException(String message, HttpStatus status) {
this(message, status, null);
}

/**
* Constructs a new AppException with the specified message, status, and args.
*
* @param message The message key or literal message
* @param status The HTTP status code to be returned in the response
* @param messageArgs Optional arguments for i18n message formatting
*/
public AppException(String message, HttpStatus status, Object[] messageArgs) {
super(message);
this.status = status;
this.messageArgs = messageArgs;
}

/**
Expand All @@ -47,5 +64,14 @@ public AppException(String message, HttpStatus status) {
public HttpStatus getStatus() {
return status;
}

/**
* Returns optional arguments for i18n message formatting.
*
* @return Arguments for the message, or null if none
*/
public Object[] getMessageArgs() {
return messageArgs;
}
}

Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package ch.sectioninformatique.template.app.exceptions;

import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
Expand Down Expand Up @@ -32,6 +34,16 @@
@ControllerAdvice
public class GlobalExceptionHandler {

private final MessageSource messageSource;

public GlobalExceptionHandler(MessageSource messageSource) {
this.messageSource = messageSource;
}

private String getMessage(String key, Object[] args, String defaultMessage) {
return messageSource.getMessage(key, args, defaultMessage, LocaleContextHolder.getLocale());
}

/**
* Builds a standardized error response entity.
*
Expand All @@ -58,15 +70,16 @@ private ResponseEntity<Object> buildResponse(HttpStatus status, String message)
/**
* Handles AppException - the custom application exception.
*
* Returns the exception's status and message as-is, allowing developers
* to customize responses directly when throwing exceptions.
* Resolves the exception's message as an i18n key (with optional args) and
* falls back to the raw message when no bundle entry exists.
*
* @param ex The AppException instance containing status and message
* @return ResponseEntity with the exception's status and message
* @param ex The AppException instance containing status, key, and args
* @return ResponseEntity with the exception's status and resolved message
*/
@ExceptionHandler(AppException.class)
public ResponseEntity<Object> handleAppException(AppException ex) {
return buildResponse(ex.getStatus(), ex.getMessage());
String message = getMessage(ex.getMessage(), ex.getMessageArgs(), ex.getMessage());
return buildResponse(ex.getStatus(), message);
}

// ========================================================================
Expand All @@ -85,7 +98,11 @@ public ResponseEntity<Object> handleAppException(AppException ex) {
*/
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<Object> handleAccessDenied(AccessDeniedException ex) {
return buildResponse(HttpStatus.FORBIDDEN, "Access denied: You do not have permission to access this resource");
String message = getMessage(
"error.accessDenied",
null,
"Access denied: You do not have permission to access this resource");
return buildResponse(HttpStatus.FORBIDDEN, message);
}

/**
Expand Down Expand Up @@ -120,7 +137,7 @@ public ResponseEntity<Object> handleValidationErrors(MethodArgumentNotValidExcep
String combinedMessage = fieldErrors.entrySet().stream()
.map(entry -> entry.getKey() + ": " + entry.getValue())
.reduce((e1, e2) -> e1 + "; " + e2)
.orElse("Validation failed");
.orElse(getMessage("error.validation.failed", null, "Validation failed"));

// Build response with both message and detailed fieldErrors
Map<String, Object> response = new HashMap<>();
Expand All @@ -145,7 +162,8 @@ public ResponseEntity<Object> handleValidationErrors(MethodArgumentNotValidExcep
*/
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public ResponseEntity<Object> handleUnsupportedMediaType(HttpMediaTypeNotSupportedException ex) {
return buildResponse(HttpStatus.UNSUPPORTED_MEDIA_TYPE, ex.getMessage());
String message = getMessage("error.mediaTypeNotSupported", null, "Unsupported media type");
return buildResponse(HttpStatus.UNSUPPORTED_MEDIA_TYPE, message);
}

/**
Expand All @@ -162,7 +180,11 @@ public ResponseEntity<Object> handleUnsupportedMediaType(HttpMediaTypeNotSupport
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<Object> handleMissingParams(MissingServletRequestParameterException ex) {
return buildResponse(HttpStatus.BAD_REQUEST, ex.getParameterName() + " parameter is missing");
String message = getMessage(
"error.missingParameter",
new Object[] { ex.getParameterName() },
ex.getParameterName() + " parameter is missing");
return buildResponse(HttpStatus.BAD_REQUEST, message);
}

/**
Expand All @@ -187,7 +209,10 @@ public ResponseEntity<Object> handleMissingParams(MissingServletRequestParameter
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<Object> handleMalformedJson(HttpMessageNotReadableException ex) {
String message = "Malformed or missing JSON request body";
String message = getMessage(
"error.json.malformed",
null,
"Malformed or missing JSON request body");

// Extract more specific error information if available
Throwable cause = ex.getCause();
Expand All @@ -196,13 +221,25 @@ public ResponseEntity<Object> handleMalformedJson(HttpMessageNotReadableExceptio
// Provide more specific guidance based on the parsing error
if (causeMessage != null) {
if (causeMessage.contains("Unexpected end-of-input")) {
message = "JSON is incomplete - missing closing bracket or quote";
message = getMessage(
"error.json.incomplete",
null,
"JSON is incomplete - missing closing bracket or quote");
} else if (causeMessage.contains("Unexpected character")) {
message = "JSON contains invalid character - check for unescaped quotes or missing commas";
message = getMessage(
"error.json.invalidCharacter",
null,
"JSON contains invalid character - check for unescaped quotes or missing commas");
} else if (causeMessage.contains("cannot deserialize")) {
message = "Invalid value type for a field - check your data types match the schema";
message = getMessage(
"error.json.invalidType",
null,
"Invalid value type for a field - check your data types match the schema");
} else if (causeMessage.contains("No content to map")) {
message = "Empty or missing request body";
message = getMessage(
"error.json.empty",
null,
"Empty or missing request body");
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public class AuthExceptions {
*/
public static class InvalidCredentialsException extends AppException {
public InvalidCredentialsException() {
super("Invalid credentials", HttpStatus.UNAUTHORIZED);
super("auth.invalidCredentials", HttpStatus.UNAUTHORIZED);
}
}

Expand All @@ -32,7 +32,7 @@ public RegistrationFailedException(String message) {
*/
public static class UserNotFoundException extends AppException {
public UserNotFoundException() {
super("User not found", HttpStatus.NOT_FOUND);
super("user.notFound", HttpStatus.NOT_FOUND);
}

public UserNotFoundException(String message) {
Expand All @@ -45,7 +45,7 @@ public UserNotFoundException(String message) {
*/
public static class UserAlreadyExistsException extends AppException {
public UserAlreadyExistsException() {
super("User already exists", HttpStatus.CONFLICT);
super("auth.userAlreadyExists", HttpStatus.CONFLICT);
}

public UserAlreadyExistsException(String message) {
Expand Down Expand Up @@ -76,7 +76,7 @@ public OAuth2AuthenticationException(String message) {
*/
public static class LoginAlreadyExistsException extends AppException {
public LoginAlreadyExistsException() {
super("Login already exists", HttpStatus.BAD_REQUEST);
super("auth.loginAlreadyExists", HttpStatus.BAD_REQUEST);
}

public LoginAlreadyExistsException(String message) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public static class ItemNotFoundException extends AppException {
* @param id The ID of the item that was not found
*/
public ItemNotFoundException(Long id) {
super("Could not find item " + id,HttpStatus.NOT_FOUND);
super("item.notFound",HttpStatus.NOT_FOUND);
}
}

Expand All @@ -28,7 +28,7 @@ public static class UnauthorizedItemException extends AppException {
* @param message The operation that was attempted (e.g., "update", "delete")
*/
public UnauthorizedItemException(String message) {
super("You can only " + message + " your own items",HttpStatus.UNAUTHORIZED);
super("item.unauthorized",HttpStatus.UNAUTHORIZED);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;

import ch.sectioninformatique.template.app.exceptions.AppException;
import ch.sectioninformatique.template.item.ItemExceptions.ItemNotFoundException;
import ch.sectioninformatique.template.item.ItemExceptions.UnauthorizedItemException;
import ch.sectioninformatique.template.user.User;
Expand Down Expand Up @@ -80,7 +82,7 @@ public Item createItem(Item newItem) {
}

User author = userRepository.findByLogin(currentUserEmail)
.orElseThrow(() -> new RuntimeException("User not found"));
.orElseThrow(() -> new AppException("user.notFound", HttpStatus.NOT_FOUND));

newItem.setAuthor(author);

Expand Down Expand Up @@ -118,7 +120,7 @@ public void deleteItem(final Long id) {
String currentUserEmail = getCurrentUserEmail();

User currentUser = userRepository.findByLogin(currentUserEmail)
.orElseThrow(() -> new RuntimeException("User not found"));
.orElseThrow(() -> new AppException("user.notFound", HttpStatus.NOT_FOUND));

Item item = itemRepository.findById(id)
.orElseThrow(() -> new ItemNotFoundException(id));
Expand Down Expand Up @@ -150,7 +152,7 @@ public Item updateItem(Long id, Item newItem) {
String currentUserEmail = getCurrentUserEmail();

User currentUser = userRepository.findByLogin(currentUserEmail)
.orElseThrow(() -> new RuntimeException("User not found"));
.orElseThrow(() -> new AppException("user.notFound", HttpStatus.NOT_FOUND));
logger.debug("Found user with ID: {}", currentUser.getId());

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
Expand Down Expand Up @@ -47,6 +49,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
* - Manages user authentication state
*/
private final UserAuthenticationProvider userAuthenticationProvider;
private final MessageSource messageSource;

private static final ObjectMapper mapper = new ObjectMapper();

Expand Down Expand Up @@ -87,24 +90,40 @@ protected void doFilterInternal(
SecurityContextHolder.getContext().setAuthentication(
userAuthenticationProvider.validateToken(authElements[1]));

} catch (SecurityExceptions.JwtTokenExpiredException |
SecurityExceptions.InvalidJwtSignatureException |
SecurityExceptions.MalformedJwtException |
SecurityExceptions.InvalidTokenTypeException |
SecurityExceptions.JwtVerificationException e) {
} catch (SecurityExceptions.JwtTokenExpiredException e) {
SecurityContextHolder.clearContext();
log.debug("JWT validation failed: {}", e.getMessage());
sendErrorResponse(response, e.getMessage());
sendErrorResponse(response, getMessage("security.jwt.expired"));
return;
} catch (SecurityExceptions.InvalidJwtSignatureException e) {
SecurityContextHolder.clearContext();
log.debug("JWT validation failed: {}", e.getMessage());
sendErrorResponse(response, getMessage("security.jwt.invalidSignature"));
return;
} catch (SecurityExceptions.MalformedJwtException e) {
SecurityContextHolder.clearContext();
log.debug("JWT validation failed: {}", e.getMessage());
sendErrorResponse(response, getMessage("security.jwt.malformed"));
return;
} catch (SecurityExceptions.InvalidTokenTypeException e) {
SecurityContextHolder.clearContext();
log.debug("JWT validation failed: {}", e.getMessage());
sendErrorResponse(response, getMessage("security.jwt.invalidType"));
return;
} catch (SecurityExceptions.JwtVerificationException e) {
SecurityContextHolder.clearContext();
log.debug("JWT validation failed: {}", e.getMessage());
sendErrorResponse(response, getMessage("security.jwt.verificationFailed"));
return;
} catch (JWTVerificationException e) {
SecurityContextHolder.clearContext();
log.debug("Invalid JWT token: {}", e.getMessage());
sendErrorResponse(response, e.getMessage());
sendErrorResponse(response, getMessage("security.jwt.invalid"));
return;
} catch (InvalidTokenException e) {
SecurityContextHolder.clearContext();
log.debug("Invalid token: {}", e.getMessage());
sendErrorResponse(response, e.getMessage());
sendErrorResponse(response, getMessage("security.token.invalid"));
return;
} catch (RuntimeException e) {
// Preserve behavior for other runtime exceptions
Expand All @@ -114,7 +133,7 @@ protected void doFilterInternal(
} else if (header != null && !header.isEmpty()) {
// Header is present but malformed
log.debug("Malformed Authorization header");
sendErrorResponse(response, "Malformed Authorization header");
sendErrorResponse(response, getMessage("security.authHeader.malformed"));
return;
}
}
Expand All @@ -136,4 +155,8 @@ private void sendErrorResponse(HttpServletResponse response, String message) thr
mapper.writeValue(response.getWriter(), errorBody);
response.getWriter().flush();
}

private String getMessage(String key) {
return messageSource.getMessage(key, null, LocaleContextHolder.getLocale());
}
}
Loading