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
6 changes: 3 additions & 3 deletions CODE_REFACTOR_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ all identified issues with an aggressive approach to modernization.

## Phase 4: API Layer - Modern REST Design (Days 8-9)

### 4.1 Custom Annotations & Argument Resolvers
### 4.1 Custom Annotations & Argument Resolvers

**New Files**: `CurrentUser.java`, `CurrentUserArgumentResolver.java`, `WebConfig.java`

Expand All @@ -175,7 +175,7 @@ all identified issues with an aggressive approach to modernization.

**Breaking Changes**: Controller method signatures change (BREAKING but cleaner)

### 4.2 Controller Refactoring
### 4.2 Controller Refactoring

**Files**: `AuthController.java`, `UserController.java`

Expand All @@ -190,7 +190,7 @@ all identified issues with an aggressive approach to modernization.

**Breaking Changes**: None (internal improvements)

### 4.3 Global Exception Handler Enhancement
### 4.3 Global Exception Handler Enhancement

**File**: `GlobalExceptionHandler.java`

Expand Down
21 changes: 21 additions & 0 deletions src/main/java/org/nkcoder/annotation/CurrentUser.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.nkcoder.annotation;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Annotation to inject the current authenticated user's ID into controller method parameters.
*
* <p>Usage: {@code public ResponseEntity<?> getMe(@CurrentUser UUID userId)}
*
* <p>The userId is extracted from the request attributes set by JwtAuthenticationFilter. If no
* authenticated user is found, the resolver returns null (let Spring Security handle unauthorized
* access via security configuration).
*/
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CurrentUser {}
21 changes: 21 additions & 0 deletions src/main/java/org/nkcoder/config/WebConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.nkcoder.config;

import java.util.List;
import org.nkcoder.resolver.CurrentUserArgumentResolver;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {
private final CurrentUserArgumentResolver currentUserArgumentResolver;

public WebConfig(CurrentUserArgumentResolver currentUserArgumentResolver) {
this.currentUserArgumentResolver = currentUserArgumentResolver;
}

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(currentUserArgumentResolver);
}
}
4 changes: 2 additions & 2 deletions src/main/java/org/nkcoder/controller/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public AuthController(AuthService authService) {
@PostMapping("/register")
public ResponseEntity<ApiResponse<AuthResponse>> register(
@Valid @RequestBody RegisterRequest request) {
logger.info("Registration request for email: {}", request.email());
logger.debug("Registration request received.");

AuthResponse authResponse = authService.register(request);

Expand All @@ -43,7 +43,7 @@ public ResponseEntity<ApiResponse<AuthResponse>> register(

@PostMapping("/login")
public ResponseEntity<ApiResponse<AuthResponse>> login(@Valid @RequestBody LoginRequest request) {
logger.info("Login request for email: {}", request.email());
logger.debug("Login request received.");

AuthResponse authResponse = authService.login(request);

Expand Down
31 changes: 11 additions & 20 deletions src/main/java/org/nkcoder/controller/UserController.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package org.nkcoder.controller;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.UUID;
import org.nkcoder.annotation.CurrentUser;
import org.nkcoder.dto.common.ApiResponse;
import org.nkcoder.dto.user.ChangePasswordRequest;
import org.nkcoder.dto.user.UpdateProfileRequest;
Expand All @@ -27,11 +27,8 @@ public UserController(UserService userService) {
}

@GetMapping("/me")
public ResponseEntity<ApiResponse<UserResponse>> getMe(HttpServletRequest request) {
UUID userId = (UUID) request.getAttribute("userId");
String email = (String) request.getAttribute("email");

logger.info("Get profile request for user: {}", email);
public ResponseEntity<ApiResponse<UserResponse>> getMe(@CurrentUser UUID userId) {
logger.info("Get profile request for userId: {}", userId);

UserResponse userResponse = userService.findById(userId);

Expand All @@ -41,12 +38,9 @@ public ResponseEntity<ApiResponse<UserResponse>> getMe(HttpServletRequest reques

@PatchMapping("/me")
public ResponseEntity<ApiResponse<UserResponse>> updateMe(
@Valid @RequestBody UpdateProfileRequest request, HttpServletRequest httpRequest) {

UUID userId = (UUID) httpRequest.getAttribute("userId");
String email = (String) httpRequest.getAttribute("email");
@CurrentUser UUID userId, @Valid @RequestBody UpdateProfileRequest request) {

logger.info("Update profile request for user: {}", email);
logger.info("Update profile request for userId: {}", userId);

UserResponse userResponse = userService.updateProfile(userId, request);

Expand All @@ -55,12 +49,9 @@ public ResponseEntity<ApiResponse<UserResponse>> updateMe(

@PatchMapping("/me/password")
public ResponseEntity<ApiResponse<Void>> changeMyPassword(
@Valid @RequestBody ChangePasswordRequest request, HttpServletRequest httpRequest) {

UUID userId = (UUID) httpRequest.getAttribute("userId");
String email = (String) httpRequest.getAttribute("email");
@CurrentUser UUID userId, @Valid @RequestBody ChangePasswordRequest request) {

logger.info("Change password request for user: {}", email);
logger.info("Change password request for userId: {}", userId);

userService.changePassword(userId, request);

Expand All @@ -71,7 +62,7 @@ public ResponseEntity<ApiResponse<Void>> changeMyPassword(
@GetMapping("/{userId}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<UserResponse>> getUser(@PathVariable UUID userId) {
logger.info("Admin get user request for user: {}", userId);
logger.info("Admin get user request for userId: {}", userId);

UserResponse userResponse = userService.findById(userId);

Expand All @@ -83,7 +74,7 @@ public ResponseEntity<ApiResponse<UserResponse>> getUser(@PathVariable UUID user
public ResponseEntity<ApiResponse<UserResponse>> updateUser(
@PathVariable UUID userId, @Valid @RequestBody UpdateProfileRequest request) {

logger.info("Admin update user request for user: {}", userId);
logger.info("Admin update user request for userId: {}", userId);

UserResponse userResponse = userService.updateProfile(userId, request);

Expand All @@ -93,9 +84,9 @@ public ResponseEntity<ApiResponse<UserResponse>> updateUser(
@PatchMapping("/{userId}/password")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<Void>> changeUserPassword(
@PathVariable UUID userId, @RequestBody ChangePasswordRequest request) {
@PathVariable UUID userId, @Valid @RequestBody ChangePasswordRequest request) {

logger.info("Admin change password request for user: {}", userId);
logger.info("Admin change password request for userId: {}", userId);

// For admin, we only use the newPassword field
userService.changeUserPassword(userId, request.newPassword());
Expand Down
71 changes: 42 additions & 29 deletions src/main/java/org/nkcoder/exception/GlobalExceptionHandler.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package org.nkcoder.exception;

import com.fasterxml.jackson.core.JsonParseException;
import java.util.HashMap;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.stream.Collectors;
import org.nkcoder.dto.common.ApiResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand Down Expand Up @@ -51,20 +52,21 @@ public ResponseEntity<ApiResponse<Object>> handleAccessDeniedException(AccessDen
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Object>> handleValidationExceptions(
MethodArgumentNotValidException e) {
logger.error("Validation (invalid method argument) error: {}", e.getMessage());

Map<String, String> errors = new HashMap<>();
e.getBindingResult()
.getAllErrors()
.forEach(
(error) -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
logger.debug("Validation error: {} field(s) failed", e.getBindingResult().getErrorCount());

Map<String, String> errors =
e.getBindingResult().getFieldErrors().stream()
.collect(
Collectors.toMap(
FieldError::getField,
fieldError ->
fieldError.getDefaultMessage() != null
? fieldError.getDefaultMessage()
: "Invalid value",
(existing, replacement) -> existing));

return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("Validation failed"));
.body(new ApiResponse<>("Validation failed", errors, LocalDateTime.now()));
}

@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
Expand All @@ -75,26 +77,37 @@ public ResponseEntity<ApiResponse<Object>> handleRequestMethodNotSupportedExcept
.body(ApiResponse.error("Method not allowed: " + e.getMessage()));
}

@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiResponse<Object>> handleHttpMessageNotReadableException(
HttpMessageNotReadableException e) {
logger.debug("Message not readable: {}", e.getMostSpecificCause().getMessage());

String message = "Malformed JSON request";
Throwable cause = e.getCause();
if (cause instanceof JsonParseException) {
message = "Invalid JSON format: " + cause.getMessage();
}

return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(message));
}

@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public ResponseEntity<ApiResponse<Object>> handleHttpMediaTypeNotSupportedException(
HttpMediaTypeNotSupportedException e) {
logger.debug("Unsupported media type: {}", e.getContentType());

String message =
String.format(
"Content type '%s' is not supported. Use 'application/json'", e.getContentType());
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
.body(ApiResponse.error(message));
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Object>> handleGenericException(Exception e) {
logger.error("Unexpected error: {}", e.getMessage(), e);
if (e instanceof JsonParseException) {
logger.error("JSON parsing error: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("Invalid JSON format"));
}
if (e instanceof HttpMediaTypeNotSupportedException) {
logger.error("Unsupported media type: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
.body(ApiResponse.error("Unsupported media type"));
}
if (e instanceof HttpMessageNotReadableException) {
logger.error("Message not readable: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("Message not readable"));
}

return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("Internal server error"));
.body(ApiResponse.error("An unexpected error occurred. Please try again later."));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package org.nkcoder.resolver;

import jakarta.servlet.http.HttpServletRequest;
import java.util.UUID;
import org.jetbrains.annotations.NotNull;
import org.nkcoder.annotation.CurrentUser;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

/**
* Resolves method parameters annotated with {@link CurrentUser} by extracting the user ID from
* request attributes set by {@link org.nkcoder.security.JwtAuthenticationFilter}
*/
@Component
public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {

private static final String USER_ID_ATTRIBUTE = "userId";

@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(CurrentUser.class)
&& parameter.getParameterType().equals(UUID.class);
}

@Override
public Object resolveArgument(
@NotNull MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) {
HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
if (request == null) {
return null;
}

Object userId = request.getAttribute(USER_ID_ATTRIBUTE);
if (userId instanceof UUID) {
return userId;
}

return null;
}
}
4 changes: 3 additions & 1 deletion src/test/java/org/nkcoder/controller/UserControllerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,9 @@ void shouldChangeUserPasswordAsAdmin() throws Exception {
// Given
ChangePasswordRequest request =
new ChangePasswordRequest(
"", "NewAdminPassword123!", ""); // Only newPassword is used for admin
"OldPassword123!",
"NewAdminPassword123!",
"NewAdminPassword123!"); // Only newPassword is used for admin
doNothing().when(userService).changeUserPassword(testUserId, "NewAdminPassword123!");

// When & Then
Expand Down
Loading