Skip to content
Merged
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
2a626d1
Add project service module with key generation and existence checks
angelmp01 Mar 5, 2026
77a4dd8
Bump version to 0.0.3 in pom.xml for all modules
angelmp01 Mar 5, 2026
e6fd774
Rename external service project files and update artifactId in pom.xml
angelmp01 Mar 5, 2026
059a5d3
Add API module for project management with create and retrieve functi…
angelmp01 Mar 6, 2026
bd6f019
Update API base path to /api/pub/v0 and refactor ProjectServiceImpl c…
angelmp01 Mar 12, 2026
ef84f80
Add database module with Liquibase integration and JPA Repository
jorge-romero Mar 3, 2026
e53a810
Add API module for project management with create and retrieve functi…
angelmp01 Mar 6, 2026
8760581
Refactor project service integration tests and enhance response handling
angelmp01 Mar 12, 2026
54dd88f
Update API base path in integration tests to /api/pub/v0 and add new …
angelmp01 Mar 12, 2026
6fd0367
Add Spring Boot DevTools dependency for improved development experience
angelmp01 Mar 12, 2026
fbd572d
Add project service implementation with create and retrieve functiona…
angelmp01 Mar 12, 2026
2099073
Refactor MapStruct versioning in pom.xml and update ProjectServiceImp…
angelmp01 Mar 12, 2026
82279e1
Remove unused test for external service calls in ProjectServiceImplTest
angelmp01 Mar 13, 2026
8ffa571
Add JPA configuration to DevstackApiServiceApplication and remove fro…
angelmp01 Mar 13, 2026
1d81b0f
Refactor project request and response models, update mappings, and ad…
angelmp01 Mar 16, 2026
40af2da
Add projectFlavor field to response models, update error handling, an…
angelmp01 Mar 17, 2026
1f4441c
Refactor project response handling to include location header and imp…
angelmp01 Mar 18, 2026
632c387
Add ErrorKey enum for standardized error handling in project responses
angelmp01 Mar 18, 2026
5473269
Refactor ErrorKey enum to use ErrorMessage constants for improved err…
angelmp01 Mar 18, 2026
b3f6406
Prevent instantiation of ErrorMessage class by making the constructor…
angelmp01 Mar 18, 2026
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
4 changes: 4 additions & 0 deletions api-project/openapi/api-project.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ components:
type: string
status:
type: string
projectFlavor:
type: string
message:
type: string
error:
Expand All @@ -150,3 +152,5 @@ components:
type: string
errorDescription:
type: string
location:
type: string
4 changes: 2 additions & 2 deletions api-project/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.6.3</version>
<version>${mapstruct.version}</version>
</dependency>

<dependency>
Expand Down Expand Up @@ -111,7 +111,7 @@
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.6.3</version>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,38 +26,51 @@ public class ProjectController implements ProjectsApi {

public static final String API_BASE_PATH = "/api/pub/v0/projects";

private static final String HTTP_HEADER_LOCATION = "Location";

private final ProjectsFacade projectsFacade;

@PostMapping
@Override
public ResponseEntity<CreateProjectResponse> createProject(@Valid @RequestBody CreateProjectRequest createProjectRequest) {
try {
return ResponseEntity.ok(projectsFacade.createProject(createProjectRequest));
return ResponseEntity
.status(HttpStatus.OK)
.header(HTTP_HEADER_LOCATION, API_BASE_PATH)
.body(projectsFacade.createProject(createProjectRequest));
} catch (ProjectCreationException e) {
log.error("Project creation conflict: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ProjectResponseFactory.conflict(e.getMessage()));
.header(HTTP_HEADER_LOCATION, API_BASE_PATH)
.body(ProjectResponseFactory.conflict(e.getMessage(), API_BASE_PATH));
} catch (ProjectKeyGenerationException e) {
log.error("Failed to generate project key: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ProjectResponseFactory.projectKeyGenerationFailed());
.header(HTTP_HEADER_LOCATION, API_BASE_PATH)
.body(ProjectResponseFactory.projectKeyGenerationFailed(API_BASE_PATH));
}
}

@GetMapping("/{projectKey}")
@Override
public ResponseEntity<CreateProjectResponse> getProject(@PathVariable String projectKey) {
String location = API_BASE_PATH + "/" + projectKey;
try {
CreateProjectResponse response = projectsFacade.getProject(projectKey);
if (response == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ProjectResponseFactory.notFound(projectKey));
.header(HTTP_HEADER_LOCATION, location)
.body(ProjectResponseFactory.notFound(projectKey, location));
}
return ResponseEntity.ok(response);
} catch (ProjectCreationException e) {
log.error("Error retrieving project '{}': {}", projectKey, e.getMessage(), e);
return ResponseEntity
.status(HttpStatus.OK)
.header(HTTP_HEADER_LOCATION, location)
.body(response);
} catch (Exception e) {
log.error("Unexpected error retrieving project '{}': {}", projectKey, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ProjectResponseFactory.internalError());
.header(HTTP_HEADER_LOCATION, location)
.body(ProjectResponseFactory.internalError(location));
}
}
}
Original file line number Diff line number Diff line change
@@ -1,38 +1,50 @@
package org.opendevstack.apiservice.project.controller;

import org.opendevstack.apiservice.project.exception.ErrorKey;
import org.opendevstack.apiservice.project.model.CreateProjectResponse;

public final class ProjectResponseFactory {

private static final String INTERNAL_ERROR = "INTERNAL_ERROR";

private ProjectResponseFactory() {
}

public static CreateProjectResponse conflict(String message) {
return error("CONFLICT", "PROJECT_ALREADY_EXISTS", message);
public static CreateProjectResponse conflict(String message, String location) {
return error(
ErrorKey.PROJECT_ALREADY_EXISTS.getMessage(),
ErrorKey.PROJECT_ALREADY_EXISTS.getKey(),
message, location);
}

public static CreateProjectResponse projectKeyGenerationFailed() {
return error(INTERNAL_ERROR, "PROJECT_KEY_GENERATION_FAILED",
"Failed to generate a unique project key.");
public static CreateProjectResponse projectKeyGenerationFailed(String location) {
return error(ErrorKey.INTERNAL_ERROR.getMessage(),
"PROJECT_KEY_GENERATION_FAILED",
"Failed to generate a unique project key.",
location);
}

public static CreateProjectResponse notFound(String projectKey) {
return error("NOT_FOUND", "PROJECT_NOT_FOUND",
String.format("Project with key '%s' not found", projectKey));
public static CreateProjectResponse notFound(String projectKey, String location) {
return error(
ErrorKey.PROJECT_NOT_FOUND.getMessage(),
ErrorKey.PROJECT_NOT_FOUND.getKey(),
String.format("Project with key '%s' not found", projectKey),
location
);
}

public static CreateProjectResponse internalError() {
return error(INTERNAL_ERROR, INTERNAL_ERROR,
"An error occurred while processing the request.");
public static CreateProjectResponse internalError(String location) {
return error(
ErrorKey.INTERNAL_ERROR.getMessage(),
ErrorKey.INTERNAL_ERROR.getKey(),
"An error occurred while processing the request.",
location);
}

private static CreateProjectResponse error(String error, String errorKey, String message) {
private static CreateProjectResponse error(String error, String errorKey, String message, String location) {
CreateProjectResponse response = new CreateProjectResponse();
response.setError(error);
response.setErrorKey(errorKey);
response.setMessage(message);
response.setLocation(location);
return response;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package org.opendevstack.apiservice.project.exception;

public enum ErrorKey {

OK("000", "Success"),
PRODUCT_NOT_FOUND("001", ErrorMessage.NOT_FOUND),
ACCESS_DENIED("002", ErrorMessage.FORBIDDEN),
INTERNAL_ERROR("003", "Internal error"),
INVALID_AUTH_HEADER("004", ErrorMessage.BAD_REQUEST),
MISSING_AUTH_HEADER("005", ErrorMessage.BAD_REQUEST),
INVALID_PARAMETERS("006", ErrorMessage.BAD_REQUEST),
X2_ACCOUNT_MISSING_GROUPS("007", ErrorMessage.NOT_FOUND),
ONLY_INVITED_PROJECT("008", ErrorMessage.FORBIDDEN),
ONCE_PER_PROJECT("009", ErrorMessage.FORBIDDEN),
COMPONENT_PARAM_NOT_MEET_REGEX("010", ErrorMessage.BAD_REQUEST),
INVALID_LOCATION("011", ErrorMessage.BAD_REQUEST),
PROJECT_NOT_FOUND("012", ErrorMessage.NOT_FOUND),
COMPONENT_NOT_FOUND("013", ErrorMessage.NOT_FOUND),
BAD_REQUEST_BODY("014", ErrorMessage.BAD_REQUEST),
FORBIDDEN("015", ErrorMessage.FORBIDDEN),
DUPLICATE_RECORD("016", "Record already exists"),
COMPONENT_PARAM_INVALID_FORMAT("017", ErrorMessage.BAD_REQUEST),
PROJECT_KEY_INVALID_FORMAT("018", "projectKey not met the pattern ^[A-Z] {2}[A-Z0-9] {1,8}$"),
PROJECT_NAME_INVALID_FORMAT("019", "projectName not met the pattern ^[A-Za-z0-9 ] {0,80}$"),
PROJECT_DESCRIPTION_INVALID_FORMAT("020", "projectDescription not met the pattern ^.{0,255}$"),
PROJECT_OWNER_INVALID_FORMAT("021", "projectOwner not met the pattern ^[a-z]{1,10}$"),
PROJECT_X2ACCOUNT_INVALID_FORMAT("022", "projectX2Account not met the pattern ^x2[a-zA-Z0-9]{0,13}$"),
BAD_REQUEST_FLAVOR_CONFIG_ITEM("023", "Project flavour and config item cannot be both null"),
MANDATORY_OWNER("024", "Owner must be present if the X2 account is present"),
PROJECT_ALREADY_EXISTS("025", "Project already exists"),
PROJECT_SAME_PROJECT_NAME_ALREADY_EXISTS("026", "Project with same project name already exists");

private String key;
private String message;

ErrorKey(String key, String message) {
this.key = key;
this.message = message;
}

public String getKey() {
return this.key;
}

public String getMessage() {
return this.message;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.opendevstack.apiservice.project.exception;

public class ErrorMessage {

public static final String NOT_FOUND = "Not Found";
public static final String FORBIDDEN = "Forbidden";
public static final String BAD_REQUEST = "Bad Request";

private ErrorMessage() {
// prevent instantiation
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ public interface ProjectsFacade {
CreateProjectResponse createProject(CreateProjectRequest request)
throws ProjectCreationException, ProjectKeyGenerationException;

CreateProjectResponse getProject(String projectKey) throws ProjectCreationException;
CreateProjectResponse getProject(String projectKey);
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public CreateProjectResponse createProject(CreateProjectRequest request)
}

@Override
public CreateProjectResponse getProject(String projectKey) throws ProjectCreationException {
public CreateProjectResponse getProject(String projectKey) {
return projectMapper.toApiResponse(projectService.getProject(projectKey));
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,49 @@
package org.opendevstack.apiservice.project.mapper;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something like this is more close to the use of MapStuct

package org.opendevstack.apiservice.project.mapper;

import org.apache.commons.lang3.StringUtils;
import org.mapstruct.*;
import org.opendevstack.apiservice.project.model.CreateProjectRequest;
import org.opendevstack.apiservice.project.model.CreateProjectResponse;
import org.opendevstack.apiservice.serviceproject.model.ProjectRequest;
import org.opendevstack.apiservice.serviceproject.model.ProjectResponse;

@Mapper(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR,
nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS
)
public interface ProjectMapper {

// API -> Service
ProjectRequest toServiceRequest(CreateProjectRequest apiRequest);

// Service -> API (mapeo base)
@Mapping(target = "projectKey", source = "projectKey")
@Mapping(target = "status", source = "status", qualifiedByName = "emptyToNull")
// location in @AfterMapping so you have the projectkey value mappend if exists.
CreateProjectResponse toApiResponse(ProjectResponse serviceResponse);

// Helper: mapd"" -> null (not empty string )
@Named("emptyToNull")
default String emptyToNull(String value) {
    return StringUtils.isBlank(value) ? null : value.trim();
}

// Post-processing: location composed if projectkey exists
@AfterMapping
default void buildLocation(@MappingTarget CreateProjectResponse target, ProjectResponse source) {
    if (StringUtils.isNotBlank(source.getProjectKey())) {
        target.setLocation("/api/pub/v0/projects/" + source.getProjectKey().trim());
    }
}

}


import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Named;
import org.opendevstack.apiservice.project.model.CreateProjectRequest;
import org.opendevstack.apiservice.project.model.CreateProjectResponse;
import org.opendevstack.apiservice.serviceproject.model.ProjectRequest;
import org.opendevstack.apiservice.serviceproject.model.ProjectResponse;
import org.opendevstack.apiservice.serviceproject.model.Status;

import java.text.MessageFormat;

@Mapper(componentModel = "spring")
public interface ProjectMapper {

org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest toServiceRequest(
CreateProjectRequest apiRequest);

CreateProjectResponse toApiResponse(
org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse serviceResponse);

ProjectRequest toServiceRequest(CreateProjectRequest apiRequest);

@Mapping(source = "status", target = "status", qualifiedByName = "mapStatus")
@Mapping(source = "projectKey", target = "location", qualifiedByName = "mapLocation")
@Mapping(source = ".", target = "errorDescription", qualifiedByName = "mapErrorDescription")
CreateProjectResponse toApiResponse(ProjectResponse serviceResponse);

@Named("mapStatus")
default String mapStatus(Status status) {
if (status == null) {
return null;
}
return status.getDbValue();
}

@Named("mapErrorDescription")
default String mapErrorDescription(ProjectResponse serviceResponse) {
return (serviceResponse.getStatus() == Status.FAILED)
? MessageFormat.format(
"There was an error when creating the project {0}.\n\n " +
"The error has been reported to our Support team as an incident. " +
"You will be informed about the incident via email.", serviceResponse.getProjectKey())

Check warning on line 38 in api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this String concatenation with Text block.

See more on https://sonarcloud.io/project/issues?id=opendevstack_ods-api-service&issues=AZ0A--AgTGLT7iqVMHAZ&open=AZ0A--AgTGLT7iqVMHAZ&pullRequest=9
: null;
}

@Named("mapLocation")
default String mapLocation(String projectKey) {
if (projectKey == null || projectKey.isEmpty()) {
return null;
}
return "/api/pub/v0/projects/" + projectKey;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ void createProject_whenSuccess_thenReturnOk() throws Exception {
assertThat(result.getBody()).isNotNull();
assertThat(result.getBody().getProjectKey()).isEqualTo("PROJ01");
assertThat(result.getBody().getStatus()).isEqualTo("Initiated");
assertThat(result.getBody().getError()).isNull();
assertThat(result.getBody().getErrorKey()).isNull();
assertThat(result.getBody().getErrorDescription()).isNull();
}

@Test
Expand All @@ -65,9 +68,12 @@ void createProject_whenProjectCreationException_thenReturnConflict() throws Exce

assertThat(result.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
assertThat(result.getBody()).isNotNull();
assertThat(result.getBody().getError()).isEqualTo("CONFLICT");
assertThat(result.getBody().getErrorKey()).isEqualTo("PROJECT_ALREADY_EXISTS");
assertThat(result.getBody().getMessage()).contains("already exists");
assertThat(result.getBody().getError()).isEqualTo("Project already exists");
assertThat(result.getBody().getErrorKey()).isEqualTo("025");
assertThat(result.getBody().getMessage()).contains("Project with key 'EXISTING' already exists");
assertThat(result.getBody().getProjectKey()).isNull();
assertThat(result.getBody().getStatus()).isNull();
assertThat(result.getBody().getErrorDescription()).isNull();
}

@Test
Expand All @@ -81,9 +87,12 @@ void createProject_whenProjectKeyGenerationException_thenReturnInternalServerErr

assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(result.getBody()).isNotNull();
assertThat(result.getBody().getError()).isEqualTo("INTERNAL_ERROR");
assertThat(result.getBody().getError()).isEqualTo("Internal error");
assertThat(result.getBody().getErrorKey()).isEqualTo("PROJECT_KEY_GENERATION_FAILED");
assertThat(result.getBody().getMessage()).isEqualTo("Failed to generate a unique project key.");
assertThat(result.getBody().getProjectKey()).isNull();
assertThat(result.getBody().getStatus()).isNull();
assertThat(result.getBody().getErrorDescription()).isNull();
}

@Test
Expand All @@ -99,6 +108,8 @@ void getProject_whenFound_thenReturnOk() throws Exception {
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(result.getBody()).isNotNull();
assertThat(result.getBody().getProjectKey()).isEqualTo("PROJ01");
assertThat(result.getBody().getError()).isNull();
assertThat(result.getBody().getErrorKey()).isNull();
verify(projectsFacade).getProject("PROJ01");
}

Expand All @@ -110,23 +121,29 @@ void getProject_whenNotFound_thenReturnNotFound() throws Exception {

assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
assertThat(result.getBody()).isNotNull();
assertThat(result.getBody().getError()).isEqualTo("NOT_FOUND");
assertThat(result.getBody().getErrorKey()).isEqualTo("PROJECT_NOT_FOUND");
assertThat(result.getBody().getError()).isEqualTo("Not Found");
assertThat(result.getBody().getErrorKey()).isEqualTo("012");
assertThat(result.getBody().getMessage()).contains("UNKNOWN");
assertThat(result.getBody().getProjectKey()).isNull();
assertThat(result.getBody().getStatus()).isNull();
assertThat(result.getBody().getErrorDescription()).isNull();
}

@Test
void getProject_whenServiceThrows_thenReturnInternalServerError() throws Exception {
when(projectsFacade.getProject(anyString()))
.thenThrow(new ProjectCreationException("Database error"));
.thenThrow(new RuntimeException("Database error"));

ResponseEntity<CreateProjectResponse> result = sut.getProject("PROJ01");

assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(result.getBody()).isNotNull();
assertThat(result.getBody().getError()).isEqualTo("INTERNAL_ERROR");
assertThat(result.getBody().getErrorKey()).isEqualTo("INTERNAL_ERROR");
assertThat(result.getBody().getError()).isEqualTo("Internal error");
assertThat(result.getBody().getErrorKey()).isEqualTo("003");
assertThat(result.getBody().getMessage()).isEqualTo("An error occurred while processing the request.");
assertThat(result.getBody().getProjectKey()).isNull();
assertThat(result.getBody().getStatus()).isNull();
assertThat(result.getBody().getErrorDescription()).isNull();
}

}
Loading
Loading