Skip to content

Commit 9e4b4ce

Browse files
committed
Feature/add project request validation and exception handling
1 parent 8f7c7fa commit 9e4b4ce

11 files changed

Lines changed: 430 additions & 174 deletions

File tree

api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandler.java renamed to api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/controller/advice/ProjectUserExceptionHandler.java

Lines changed: 102 additions & 96 deletions
Large diffs are not rendered by default.

api-project-users/src/test/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandlerTest.java renamed to api-project-users/src/test/java/org/opendevstack/apiservice/projectusers/controller/advice/ProjectUserExceptionHandlerTest.java

Lines changed: 34 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,44 @@
1-
package org.opendevstack.apiservice.projectusers.exception;
2-
1+
package org.opendevstack.apiservice.projectusers.controller.advice;
2+
import org.junit.jupiter.api.AfterEach;
3+
import org.junit.jupiter.api.BeforeEach;
4+
import org.junit.jupiter.api.Test;
5+
import org.mockito.MockitoAnnotations;
36
import org.opendevstack.apiservice.projectusers.controller.ProjectUserController;
47
import org.opendevstack.apiservice.projectusers.model.AddUserToProjectRequest;
58
import org.opendevstack.apiservice.projectusers.model.ValidationErrorResponse;
6-
import org.junit.jupiter.api.BeforeEach;
7-
import org.junit.jupiter.api.Test;
9+
import org.springframework.core.MethodParameter;
810
import org.springframework.http.HttpStatus;
911
import org.springframework.http.ResponseEntity;
1012
import org.springframework.validation.BeanPropertyBindingResult;
1113
import org.springframework.validation.FieldError;
1214
import org.springframework.web.bind.MethodArgumentNotValidException;
13-
import org.springframework.core.MethodParameter;
14-
1515
import java.util.List;
16-
17-
import static org.junit.jupiter.api.Assertions.*;
18-
16+
import static org.junit.jupiter.api.Assertions.assertEquals;
17+
import static org.junit.jupiter.api.Assertions.assertFalse;
18+
import static org.junit.jupiter.api.Assertions.assertNotNull;
19+
import static org.junit.jupiter.api.Assertions.assertTrue;
20+
import static org.junit.jupiter.api.Assertions.fail;
1921
/**
20-
* Unit test class for the GlobalExceptionHandler to verify improved validation
22+
* Unit test class for the ProjectUserExceptionHandler to verify improved validation
2123
* error messages.
2224
*/
23-
class GlobalExceptionHandlerTest {
24-
25-
private GlobalExceptionHandler exceptionHandler;
26-
25+
class ProjectUserExceptionHandlerTest {
26+
private ProjectUserExceptionHandler sut;
27+
private AutoCloseable mocks;
2728
@BeforeEach
2829
void setUp() {
29-
exceptionHandler = new GlobalExceptionHandler();
30+
mocks = MockitoAnnotations.openMocks(this);
31+
sut = new ProjectUserExceptionHandler();
32+
}
33+
@AfterEach
34+
void tearDown() throws Exception {
35+
mocks.close();
3036
}
31-
3237
@Test
33-
void testValidationErrorHandling() {
34-
// Create a mock MethodArgumentNotValidException with validation errors
35-
// Target object representing the @RequestBody argument
38+
void handle_method_argument_not_valid_exception_returns_bad_request_with_field_errors() {
39+
// GIVEN
3640
AddUserToProjectRequest target = new AddUserToProjectRequest();
3741
BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, "addUserToProjectRequest");
38-
39-
// Add field errors for required fields
4042
bindingResult.addError(new FieldError("addUserToProjectRequest", "environment", null, false, null, null,
4143
"Environment cannot be blank"));
4244
bindingResult.addError(
@@ -45,45 +47,32 @@ void testValidationErrorHandling() {
4547
"Account cannot be blank"));
4648
bindingResult.addError(
4749
new FieldError("addUserToProjectRequest", "role", null, false, null, null, "Role cannot be null"));
48-
49-
// Create a MethodParameter referencing the controller method's @RequestBody
50-
// parameter
5150
MethodParameter methodParameter;
5251
try {
5352
methodParameter = new MethodParameter(
5453
ProjectUserController.class.getMethod(
5554
"triggerMembershipRequest", String.class, AddUserToProjectRequest.class),
56-
1 // index of AddUserToProjectRequest parameter
57-
);
55+
1);
5856
} catch (NoSuchMethodException e) {
5957
fail("Failed to reflect controller method for test: " + e.getMessage());
60-
return; // unreachable, but required for compilation
58+
return;
6159
}
62-
6360
MethodArgumentNotValidException exception = new MethodArgumentNotValidException(methodParameter, bindingResult);
64-
65-
// Test the exception handler
66-
ResponseEntity<ValidationErrorResponse> response = exceptionHandler
67-
.handleMethodArgumentNotValidException(exception);
68-
69-
// Verify response
61+
// WHEN
62+
ResponseEntity<ValidationErrorResponse> response = sut.handleMethodArgumentNotValidException(exception);
63+
// THEN
7064
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
7165
assertNotNull(response.getBody());
72-
7366
ValidationErrorResponse errorResponse = response.getBody();
7467
assertFalse(errorResponse.getSuccess());
7568
assertEquals("PROJECT_USER_ERROR", errorResponse.getErrorCode());
7669
assertNotNull(errorResponse.getFieldErrors());
7770
assertEquals(4, errorResponse.getFieldErrors().size());
78-
79-
// Check specific field errors
8071
List<org.opendevstack.apiservice.projectusers.model.FieldError> fieldErrors = errorResponse.getFieldErrors();
8172
assertTrue(fieldErrors.stream().anyMatch(error -> "environment".equals(error.getField())));
8273
assertTrue(fieldErrors.stream().anyMatch(error -> "user".equals(error.getField())));
8374
assertTrue(fieldErrors.stream().anyMatch(error -> "account".equals(error.getField())));
8475
assertTrue(fieldErrors.stream().anyMatch(error -> "role".equals(error.getField())));
85-
86-
// Verify expected format is provided for each field
8776
fieldErrors.forEach(fieldError -> {
8877
assertNotNull(fieldError.getField());
8978
assertNotNull(fieldError.getMessage());
@@ -93,15 +82,14 @@ void testValidationErrorHandling() {
9382
}
9483
});
9584
}
96-
9785
@Test
98-
void testGenericExceptionHandling() {
99-
// Test generic exception handling
86+
void handle_generic_exception_returns_internal_server_error() {
87+
// GIVEN
10088
Exception exception = new RuntimeException("Unexpected error");
101-
102-
ResponseEntity<?> response = exceptionHandler.handleGenericException(exception);
103-
89+
// WHEN
90+
ResponseEntity<?> response = sut.handleGenericException(exception);
91+
// THEN
10492
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
10593
assertNotNull(response.getBody());
10694
}
107-
}
95+
}

api-project/openapi/api-project.yaml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,15 @@ components:
133133
projectDescription:
134134
type: string
135135
description: Description of the project.
136-
required:
137-
- projectName
136+
projectFlavor:
137+
type: string
138+
description: Flavor of the project. Either projectFlavor or configurationItem must be provided.
139+
configurationItem:
140+
type: string
141+
description: Configuration item for the project. Either projectFlavor or configurationItem must be provided.
142+
location:
143+
type: string
144+
description: Location of the project.
138145
CreateProjectResponse:
139146
type: object
140147
properties:

api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import org.opendevstack.apiservice.project.model.CreateProjectRequest;
1010
import org.opendevstack.apiservice.project.model.CreateProjectResponse;
1111
import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException;
12+
import org.opendevstack.apiservice.project.validation.ProjectRequestValidator;
1213
import org.springframework.http.HttpStatus;
1314
import org.springframework.http.ResponseEntity;
1415
import org.springframework.web.bind.annotation.GetMapping;
@@ -30,9 +31,12 @@ public class ProjectController implements ProjectsApi {
3031

3132
private final ProjectsFacade projectsFacade;
3233

34+
private final ProjectRequestValidator projectRequestValidator;
35+
3336
@PostMapping
3437
@Override
3538
public ResponseEntity<CreateProjectResponse> createProject(@Valid @RequestBody CreateProjectRequest createProjectRequest) {
39+
projectRequestValidator.validate(createProjectRequest);
3640
try {
3741
return ResponseEntity
3842
.status(HttpStatus.OK)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package org.opendevstack.apiservice.project.controller.advice;
2+
3+
import lombok.extern.slf4j.Slf4j;
4+
import org.opendevstack.apiservice.project.controller.ProjectController;
5+
import org.opendevstack.apiservice.project.exception.ErrorKey;
6+
import org.opendevstack.apiservice.project.exception.ProjectValidationException;
7+
import org.opendevstack.apiservice.project.model.CreateProjectResponse;
8+
import org.springframework.http.HttpStatus;
9+
import org.springframework.http.ResponseEntity;
10+
import org.springframework.validation.FieldError;
11+
import org.springframework.web.bind.MethodArgumentNotValidException;
12+
import org.springframework.web.bind.annotation.ExceptionHandler;
13+
import org.springframework.web.bind.annotation.RestControllerAdvice;
14+
15+
import java.util.stream.Collectors;
16+
17+
@RestControllerAdvice(assignableTypes = ProjectController.class)
18+
@Slf4j
19+
public class ProjectExceptionHandler {
20+
21+
@ExceptionHandler(MethodArgumentNotValidException.class)
22+
public ResponseEntity<CreateProjectResponse> handleMethodArgumentNotValidException(
23+
MethodArgumentNotValidException ex) {
24+
log.warn("Request body validation error: {}", ex.getMessage());
25+
26+
String validationMessage = ex.getBindingResult().getFieldErrors().stream()
27+
.map(this::formatFieldError)
28+
.collect(Collectors.joining("; "));
29+
30+
if (validationMessage.isBlank()) {
31+
validationMessage = ErrorKey.BAD_REQUEST_BODY.getMessage();
32+
}
33+
34+
CreateProjectResponse response = new CreateProjectResponse();
35+
response.setLocation(ProjectController.API_BASE_PATH);
36+
response.setError(HttpStatus.BAD_REQUEST.getReasonPhrase());
37+
response.setErrorKey(ErrorKey.BAD_REQUEST_BODY.getKey());
38+
response.setMessage(validationMessage);
39+
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
40+
}
41+
42+
@ExceptionHandler(ProjectValidationException.class)
43+
public ResponseEntity<CreateProjectResponse> handleValidationException(ProjectValidationException ex) {
44+
log.warn("Validation error: {}", ex.getMessage());
45+
ErrorKey errorKey = ex.getErrorKey();
46+
CreateProjectResponse response = new CreateProjectResponse();
47+
response.setLocation(ProjectController.API_BASE_PATH);
48+
response.setError(HttpStatus.BAD_REQUEST.getReasonPhrase());
49+
response.setErrorKey(errorKey.getKey());
50+
response.setMessage(errorKey.getMessage());
51+
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
52+
}
53+
54+
private String formatFieldError(FieldError error) {
55+
return error.getField() + " " + error.getDefaultMessage();
56+
}
57+
}
58+
59+
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package org.opendevstack.apiservice.project.exception;
2+
3+
import lombok.Getter;
4+
5+
@Getter
6+
public class ProjectValidationException extends RuntimeException {
7+
8+
private final ErrorKey errorKey;
9+
10+
public ProjectValidationException(ErrorKey errorKey) {
11+
super(errorKey.getMessage());
12+
this.errorKey = errorKey;
13+
}
14+
}
15+
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package org.opendevstack.apiservice.project.validation;
2+
import org.opendevstack.apiservice.project.exception.ErrorKey;
3+
import org.opendevstack.apiservice.project.exception.ProjectValidationException;
4+
import org.opendevstack.apiservice.project.model.CreateProjectRequest;
5+
import org.springframework.stereotype.Component;
6+
7+
@Component
8+
public class ProjectRequestValidator {
9+
10+
private static final String PROJECT_KEY_PATTERN = "^[A-Z]{2}[A-Z0-9]{1,8}$";
11+
private static final String PROJECT_NAME_PATTERN = "^[A-Za-z0-9 ]{0,80}$";
12+
private static final String PROJECT_DESCRIPTION_PATTERN = "^.{0,255}$";
13+
14+
public void validate(CreateProjectRequest request) {
15+
validateProjectKey(request.getProjectKey());
16+
validateProjectName(request.getProjectName());
17+
validateProjectDescription(request.getProjectDescription());
18+
validateFlavorOrConfigItem(request);
19+
}
20+
21+
private void validateProjectKey(String projectKey) {
22+
if (projectKey != null && !projectKey.matches(PROJECT_KEY_PATTERN)) {
23+
throw new ProjectValidationException(ErrorKey.PROJECT_KEY_INVALID_FORMAT);
24+
}
25+
}
26+
27+
private void validateProjectName(String projectName) {
28+
if (projectName != null && !projectName.matches(PROJECT_NAME_PATTERN)) {
29+
throw new ProjectValidationException(ErrorKey.PROJECT_NAME_INVALID_FORMAT);
30+
}
31+
}
32+
33+
private void validateProjectDescription(String projectDescription) {
34+
if (projectDescription != null && !projectDescription.matches(PROJECT_DESCRIPTION_PATTERN)) {
35+
throw new ProjectValidationException(ErrorKey.PROJECT_DESCRIPTION_INVALID_FORMAT);
36+
}
37+
}
38+
39+
private void validateFlavorOrConfigItem(CreateProjectRequest request) {
40+
String projectFlavor = request.getProjectFlavor();
41+
String configurationItem = request.getConfigurationItem();
42+
43+
boolean hasFlavor = projectFlavor != null && !projectFlavor.trim().isEmpty();
44+
boolean hasConfigItem = configurationItem != null && !configurationItem.trim().isEmpty();
45+
46+
if (!hasFlavor && !hasConfigItem) {
47+
throw new ProjectValidationException(
48+
ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM
49+
);
50+
}
51+
}
52+
}

0 commit comments

Comments
 (0)