diff --git a/.gitignore b/.gitignore index b117982..3c453dd 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,7 @@ api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/api api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/model api-project-platform/src/main/java/org/opendevstack/apiservice/projectplatform/api api-project-platform/src/main/java/org/opendevstack/apiservice/projectplatform/model -api-project-users/.openapi-generator -/api-project-platform/.openapi-generator/ +api-project/src/main/java/org/opendevstack/apiservice/project/api +api-project/src/main/java/org/opendevstack/apiservice/project/model +**/.openapi-generator diff --git a/CHANGELOG.md b/CHANGELOG.md index a01dc46..ac89dcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Created **New API module** for managing EDP Projects with create and retrieve endpoints. +- Created **New module** external service projects to manage EDP Projects. + ### External Service Jira (`external-service-jira`) - **New module** for checking project existance in Jira (Server) - Caching for the client diff --git a/api-project-platform/pom.xml b/api-project-platform/pom.xml index 52d3186..abf2834 100644 --- a/api-project-platform/pom.xml +++ b/api-project-platform/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 api-project-platform diff --git a/api-project-users/openapi/api-project-users.yaml b/api-project-users/openapi/api-project-users.yaml index 2d0ba51..a78d1ef 100644 --- a/api-project-users/openapi/api-project-users.yaml +++ b/api-project-users/openapi/api-project-users.yaml @@ -16,7 +16,7 @@ tags: - name: Project Users description: API for managing project users and their roles paths: - /project/{projectKey}/users: + /projects/{projectKey}/users: post: tags: - Project Users @@ -89,7 +89,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ApiResponseMembershipRequestResponse" - /project/{projectKey}/users/{userid}/status: + /projects/{projectKey}/users/{userid}/status: get: tags: - Project Users diff --git a/api-project-users/pom.xml b/api-project-users/pom.xml index fbd989c..c75bf70 100644 --- a/api-project-users/pom.xml +++ b/api-project-users/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 api-project-users diff --git a/api-project/openapi/api-project.yaml b/api-project/openapi/api-project.yaml new file mode 100644 index 0000000..f734d81 --- /dev/null +++ b/api-project/openapi/api-project.yaml @@ -0,0 +1,152 @@ +openapi: 3.0.3 +info: + title: ODS API Server + description: API documentation for ODS (Open DevStack) API Service + contact: + name: ODS Team + version: v0.0.1 +servers: + - url: http://{baseurl}/api/v0 + variables: + baseurl: + default: localhost:8080 + description: Development environment +tags: +- name: Project + description: API for manage EDP projects. +paths: + /projects: + post: + tags: + - Projects + summary: Create a new project. + description: Creates a new project with the provided configuration. Generates a unique project key if not provided. + operationId: createProject + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectRequest' + responses: + '200': + description: Project creation initiated successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectResponse' + '400': + description: Invalid request body or validation error. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectResponse' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + '409': + description: A project with the specified key already exists. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectResponse' + '500': + description: Internal server error during project creation. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectResponse' + /projects/{projectKey}: + get: + tags: + - Projects + summary: Get project status by project key. + description: Returns the current status and details of the project identified by the given project key. + operationId: getProject + parameters: + - name: projectKey + in: path + required: true + schema: + type: string + description: Project key to retrieve information. + responses: + '200': + description: Project information retrieved successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectResponse' + '404': + description: Project not found. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectResponse' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + '500': + description: Internal server error. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectResponse' +components: + schemas: + RestErrorMessage: + properties: + message: + type: string + required: + - message + CreateProjectRequest: + type: object + properties: + projectKey: + type: string + description: Optional project key. If not provided, a unique key will be generated. + projectKeyPattern: + type: string + description: Optional pattern for generating the project key (e.g. 'SS%06d'). + projectName: + type: string + description: Name of the project. + projectDescription: + type: string + description: Description of the project. + required: + - projectName + CreateProjectResponse: + type: object + properties: + projectKey: + type: string + status: + type: string + message: + type: string + error: + type: string + errorKey: + type: string + errorDescription: + type: string diff --git a/api-project/pom.xml b/api-project/pom.xml new file mode 100644 index 0000000..8daceb0 --- /dev/null +++ b/api-project/pom.xml @@ -0,0 +1,163 @@ + + 4.0.0 + + + org.opendevstack.apiservice + devstack-api-service + 0.0.3 + + + api-project + API Projects + API module for managing EDP projects + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + + + org.opendevstack.apiservice + service-projects + ${project.version} + + + + io.jsonwebtoken + jjwt-api + 0.12.3 + + + + io.jsonwebtoken + jjwt-impl + 0.12.3 + runtime + + + + io.jsonwebtoken + jjwt-jackson + 0.12.3 + runtime + + + + org.projectlombok + lombok + provided + + + + org.mapstruct + mapstruct + 1.6.3 + + + + org.openapitools + jackson-databind-nullable + ${jackson-databind-nullable.version} + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-core + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + 1.6.3 + + + + + + org.openapitools + openapi-generator-maven-plugin + + + generate-api-project + + generate + + + spring + ${project.basedir} + spring-boot + ${project.basedir}/openapi/api-project.yaml + org.opendevstack.apiservice.project.api + org.opendevstack.apiservice.project.model + org.opendevstack.apiservice.project + false + false + false + false + false + false + + true + true + springdoc + true + true + true + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + + + + + diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java new file mode 100644 index 0000000..61210e7 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java @@ -0,0 +1,73 @@ +package org.opendevstack.apiservice.project.controller; + +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.project.api.ProjectsApi; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v0/projects") +@AllArgsConstructor +@Slf4j +public class ProjectController implements ProjectsApi { + + private final ProjectsFacade projectsFacade; + + @PostMapping + @Override + public ResponseEntity createProject(@Valid @RequestBody CreateProjectRequest createProjectRequest) { + try { + return ResponseEntity.ok(projectsFacade.createProject(createProjectRequest)); + } catch (ProjectCreationException e) { + log.error("Project creation conflict: {}", e.getMessage()); + CreateProjectResponse errorResponse = new CreateProjectResponse(); + errorResponse.setError("CONFLICT"); + errorResponse.setErrorKey("PROJECT_ALREADY_EXISTS"); + errorResponse.setMessage(e.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } catch (ProjectKeyGenerationException e) { + log.error("Failed to generate project key: {}", e.getMessage(), e); + CreateProjectResponse errorResponse = new CreateProjectResponse(); + errorResponse.setError("INTERNAL_ERROR"); + errorResponse.setErrorKey("PROJECT_KEY_GENERATION_FAILED"); + errorResponse.setMessage("Failed to generate a unique project key."); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + } + } + + @GetMapping("/{projectKey}") + @Override + public ResponseEntity getProject(@PathVariable String projectKey) { + try { + CreateProjectResponse response = projectsFacade.getProject(projectKey); + if (response == null) { + CreateProjectResponse notFoundResponse = new CreateProjectResponse(); + notFoundResponse.setError("NOT_FOUND"); + notFoundResponse.setErrorKey("PROJECT_NOT_FOUND"); + notFoundResponse.setMessage(String.format("Project with key '%s' not found", projectKey)); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(notFoundResponse); + } + return ResponseEntity.ok(response); + } catch (ProjectCreationException e) { + log.error("Error retrieving project '{}': {}", projectKey, e.getMessage(), e); + CreateProjectResponse errorResponse = new CreateProjectResponse(); + errorResponse.setError("INTERNAL_ERROR"); + errorResponse.setErrorKey("INTERNAL_ERROR"); + errorResponse.setMessage("An error occurred while processing the request."); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + } + } +} diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java new file mode 100644 index 0000000..87e12af --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ProjectCreationException extends Exception { + + public ProjectCreationException(String message) { + super(message); + } + + public ProjectCreationException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectException.java new file mode 100644 index 0000000..e9d8e8b --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ProjectException extends Exception { + + public ProjectException(String message) { + super(message); + } + + public ProjectException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java new file mode 100644 index 0000000..be9b66a --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ProjectKeyGenerationException extends Exception { + + public ProjectKeyGenerationException(String message) { + super(message); + } + + public ProjectKeyGenerationException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java new file mode 100644 index 0000000..9d29f13 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java @@ -0,0 +1,14 @@ +package org.opendevstack.apiservice.project.facade; + +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; + +public interface ProjectsFacade { + + CreateProjectResponse createProject(CreateProjectRequest request) + throws ProjectCreationException, ProjectKeyGenerationException; + + CreateProjectResponse getProject(String projectKey) throws ProjectCreationException; +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java new file mode 100644 index 0000000..274ad1d --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java @@ -0,0 +1,35 @@ +package org.opendevstack.apiservice.project.facade.impl; + +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.mapper.ProjectMapper; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.stereotype.Component; + +@Component("apiProjectFacadeImpl") +public class ProjectsFacadeImpl implements ProjectsFacade { + + private final ProjectService projectService; + private final ProjectMapper projectMapper; + + public ProjectsFacadeImpl( + ProjectService projectService, + ProjectMapper projectMapper) { + this.projectService = projectService; + this.projectMapper = projectMapper; + } + + @Override + public CreateProjectResponse createProject(CreateProjectRequest request) + throws ProjectCreationException, ProjectKeyGenerationException { + return projectMapper.toApiResponse(projectService.createProject(projectMapper.toServiceRequest(request))); + } + + @Override + public CreateProjectResponse getProject(String projectKey) throws ProjectCreationException { + return projectMapper.toApiResponse(projectService.getProject(projectKey)); + } +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java new file mode 100644 index 0000000..f9b24c8 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java @@ -0,0 +1,15 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.mapstruct.Mapper; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; + +@Mapper(componentModel = "spring") +public interface ProjectMapper { + + org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest toServiceRequest( + CreateProjectRequest apiRequest); + + CreateProjectResponse toApiResponse( + org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse serviceResponse); +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerIntegrationTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerIntegrationTest.java new file mode 100644 index 0000000..93b82c1 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerIntegrationTest.java @@ -0,0 +1,81 @@ +package org.opendevstack.apiservice.project.controller; + +import org.junit.jupiter.api.Test; +import org.mapstruct.factory.Mappers; +import org.opendevstack.apiservice.project.facade.impl.ProjectsFacadeImpl; +import org.opendevstack.apiservice.project.mapper.ProjectMapper; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(classes = ProjectControllerIntegrationTest.TestConfig.class) +@AutoConfigureMockMvc(addFilters = false) +class ProjectControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + @MockitoBean + private ProjectService projectService; + + @Test + void createProject_withProvidedProjectKey_returnsInitiatedResponse() throws Exception { + org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse serviceResponse = + new org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse(); + serviceResponse.setProjectKey("PROJ01"); + serviceResponse.setStatus("Initiated"); + when(projectService.createProject(org.mockito.ArgumentMatchers.any( + org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest.class))) + .thenReturn(serviceResponse); + + String payload = """ + { + \"projectKey\": \"PROJ01\", + \"projectName\": \"My Project\", + \"projectDescription\": \"desc\" + } + """; + + mockMvc.perform(post("/api/v0/projects") + .contentType("application/json") + .content(payload == null ? "" : payload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.projectKey").value("PROJ01")) + .andExpect(jsonPath("$.status").value("Initiated")); + } + + @Test + void getProject_whenNotFound_returns404() throws Exception { + when(projectService.getProject("UNKNOWN")).thenReturn(null); + + mockMvc.perform(get("/api/v0/projects/UNKNOWN")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.errorKey").value("PROJECT_NOT_FOUND")); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import({ProjectController.class, ProjectsFacadeImpl.class}) + static class TestConfig { + + @Bean + ProjectMapper projectMapper() { + return Mappers.getMapper(ProjectMapper.class); + } + } +} \ No newline at end of file diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java new file mode 100644 index 0000000..39f5fd9 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java @@ -0,0 +1,132 @@ +package org.opendevstack.apiservice.project.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ProjectControllerTest { + + @Mock + private ProjectsFacade projectsFacade; + + private ProjectController sut; + + @BeforeEach + void setup() { + sut = new ProjectController(projectsFacade); + } + + @Test + void createProject_whenSuccess_thenReturnOk() throws Exception { + CreateProjectRequest request = new CreateProjectRequest("My Project"); + request.setProjectKey("PROJ01"); + + CreateProjectResponse serviceResponse = new CreateProjectResponse(); + serviceResponse.setProjectKey("PROJ01"); + serviceResponse.setStatus("Initiated"); + serviceResponse.setMessage("The project creation process has been successfully initiated."); + + when(projectsFacade.createProject(any(CreateProjectRequest.class))) + .thenReturn(serviceResponse); + + ResponseEntity result = sut.createProject(request); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getProjectKey()).isEqualTo("PROJ01"); + assertThat(result.getBody().getStatus()).isEqualTo("Initiated"); + } + + @Test + void createProject_whenProjectCreationException_thenReturnConflict() throws Exception { + CreateProjectRequest request = new CreateProjectRequest("My Project"); + request.setProjectKey("EXISTING"); + + when(projectsFacade.createProject(any(CreateProjectRequest.class))) + .thenThrow(new ProjectCreationException("Project with key 'EXISTING' already exists")); + + ResponseEntity result = sut.createProject(request); + + 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"); + } + + @Test + void createProject_whenProjectKeyGenerationException_thenReturnInternalServerError() throws Exception { + CreateProjectRequest request = new CreateProjectRequest("My Project"); + + when(projectsFacade.createProject(any(CreateProjectRequest.class))) + .thenThrow(new ProjectKeyGenerationException("Failed to generate unique project key after 10 retries")); + + ResponseEntity result = sut.createProject(request); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(result.getBody()).isNotNull(); + 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."); + } + + @Test + void getProject_whenFound_thenReturnOk() throws Exception { + CreateProjectResponse serviceResponse = new CreateProjectResponse(); + serviceResponse.setProjectKey("PROJ01"); + serviceResponse.setStatus("Initiated"); + + when(projectsFacade.getProject("PROJ01")).thenReturn(serviceResponse); + + ResponseEntity result = sut.getProject("PROJ01"); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getProjectKey()).isEqualTo("PROJ01"); + verify(projectsFacade).getProject("PROJ01"); + } + + @Test + void getProject_whenNotFound_thenReturnNotFound() throws Exception { + when(projectsFacade.getProject("UNKNOWN")).thenReturn(null); + + ResponseEntity result = sut.getProject("UNKNOWN"); + + 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().getMessage()).contains("UNKNOWN"); + } + + @Test + void getProject_whenServiceThrows_thenReturnInternalServerError() throws Exception { + when(projectsFacade.getProject(anyString())) + .thenThrow(new ProjectCreationException("Database error")); + + ResponseEntity 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().getMessage()).isEqualTo("An error occurred while processing the request."); + } + +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java new file mode 100644 index 0000000..dc0cf00 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java @@ -0,0 +1,92 @@ +package org.opendevstack.apiservice.project.facade.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mapstruct.factory.Mappers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.opendevstack.apiservice.project.mapper.ProjectMapper; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ProjectsFacadeImplTest { + + @Mock + private ProjectService projectService; + + private final ProjectMapper projectMapper = Mappers.getMapper(ProjectMapper.class); + + private ProjectsFacadeImpl sut; + + @BeforeEach + void setup() { + sut = new ProjectsFacadeImpl(projectService, projectMapper); + } + + @Test + void createProject_whenServiceReturnsValue_thenMapToApiModel() throws Exception { + CreateProjectRequest request = new CreateProjectRequest("My Project"); + request.setProjectKey("PROJ01"); + + org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse serviceResponse = + new org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse(); + serviceResponse.setProjectKey("PROJ01"); + serviceResponse.setStatus("Initiated"); + + when(projectService.createProject(org.mockito.ArgumentMatchers.any( + org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest.class))) + .thenReturn(serviceResponse); + + CreateProjectResponse response = sut.createProject(request); + + assertThat(response).isNotNull(); + assertThat(response.getProjectKey()).isEqualTo("PROJ01"); + assertThat(response.getStatus()).isEqualTo("Initiated"); + verify(projectService).createProject(org.mockito.ArgumentMatchers.any( + org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest.class)); + } + + @Test + void createProject_whenServiceReturnsNull_thenReturnNull() throws Exception { + CreateProjectRequest request = new CreateProjectRequest("My Project"); + when(projectService.createProject(org.mockito.ArgumentMatchers.any( + org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest.class))) + .thenReturn(null); + + CreateProjectResponse response = sut.createProject(request); + + assertThat(response).isNull(); + } + + @Test + void getProject_whenServiceReturnsValue_thenMapToApiModel() throws Exception { + org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse serviceResponse = + new org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse(); + serviceResponse.setProjectKey("PROJ01"); + serviceResponse.setStatus("Found"); + + when(projectService.getProject("PROJ01")).thenReturn(serviceResponse); + + CreateProjectResponse response = sut.getProject("PROJ01"); + + assertThat(response).isNotNull(); + assertThat(response.getProjectKey()).isEqualTo("PROJ01"); + assertThat(response.getStatus()).isEqualTo("Found"); + } + + @Test + void getProject_whenServiceReturnsNull_thenReturnNull() throws Exception { + when(projectService.getProject("UNKNOWN")).thenReturn(null); + + CreateProjectResponse response = sut.getProject("UNKNOWN"); + + assertThat(response).isNull(); + } +} diff --git a/application.yaml b/application.yaml deleted file mode 100644 index e1d762a..0000000 --- a/application.yaml +++ /dev/null @@ -1,175 +0,0 @@ -logging: - level: - org.springframework: INFO - org.springframework.security: TRACE - org.opendevstack.apiservice.externalservice: DEBUG - -management: - endpoints: - web: - exposure: - include: openapi, swagger-ui, beans, caches, configprops, env, health, httpexchanges, info, loggers, mappings - endpoint: - configprops: - show-values: always - env: - show-values: always - loggers: - access: unrestricted - health: - show-details: always - show-components: always - info: - git: - # Show all build-time generated file git.properties info on /actuator/info endpoint - mode: full - httpexchanges: - recording: - # Show all available info in /actuator/httpexchanges and also in Swagger - include: request-headers, response-headers, authorization_header, cookie_headers, principal, remote_address, session_id, time_taken -springdoc: - show-actuator: true - swagger-ui: - doc-expansion: none - try-it-out-enabled: true - filter: true - tags-sorter: alpha - operations-sorter: alpha - -openapi: - servers: - - url: "https://localhost:8080" - description: "Development environment" - -otel: - service: - name: devstack-api-service-dev - version: 0.0.2 - exporter: - otlp: - endpoint: http://opentelemetry.example.com - traces: - exporter: logging,otlp - sampler: parentbased_traceidratio - sampler_arg: 1.0 - metrics: - exporter: none - resource: - attributes: service.name=devstack-api-service,service.version=0.0.2,deployment.environment=development - instrumentation: - jdbc: - enabled: false - logback-appender: - enabled: true - -# External Service Configuration -automation: - platform: - ansible: - enabled: true - base-url: ${ANSIBLE_BASE_URL:http://localhost:8080/api/v2} - username: ${ANSIBLE_USERNAME:admin} - password: ${ANSIBLE_PASSWORD:password} - timeout: ${ANSIBLE_TIMEOUT:30000} - ssl: - verify-certificates: ${ANSIBLE_SSL_VERIFY:true} - trust-store-path: ${ANSIBLE_SSL_TRUSTSTORE_PATH:} - trust-store-password: ${ANSIBLE_SSL_TRUSTSTORE_PASSWORD:} - trust-store-type: ${ANSIBLE_SSL_TRUSTSTORE_TYPE:JKS} - - uipath: - # Base URL of the UIPath Orchestrator instance - host: ${UIPATH_HOST:https://orchestrator.example.com} - - # Authentication credentials - clientId: ${UIPATH_CLIENT_ID:your-client-id} - clientSecret: ${UIPATH_CLIENT_SECRET:your-client-secret} - - # Tenancy name (default: "default") - tenancy-name: ${UIPATH_TENANCY_NAME:default} - - # Organization Unit ID for multi-tenant setups - organization-unit-id: ${UIPATH_ORGANIZATION_UNIT_ID:123456} - - # API endpoints (defaults shown, can be overridden) - login-endpoint: /api/Account/Authenticate - queue-items-endpoint: /odata/QueueItems - - # Request timeout in milliseconds - timeout: 30000 - - # SSL Configuration - ssl: - # Set to false to disable certificate verification (DEV ONLY!) - verify-certificates: ${UIPATH_SSL_VERIFY:true} - # Optional: path to custom trust store - trust-store-path: ${UIPATH_SSL_TRUST_STORE_PATH:/path/to/truststore.jks} - trust-store-password: ${TRUSTSTORE_PASSWORD:changeit} - trust-store-type: ${UIPATH_SSL_TRUST_STORE_TYPE:JKS} - - -apis: - project-users: - ansible-workflow-name: ${API_PROJECT_USERS_WORKFLOW_NAME:ansible++workflow} - token: - secret: ${API_PROJECT_USERS_TOKEN_SECRET:devstack-api-service-jwt-secret-key-256bit-change-in-production} - expiration-hours: ${API_PROJECT_USERS_TOKEN_EXPIRATION_HOURS:24} - - -externalservices: - openshift: - instances: - # Development OpenShift instance - dev: - api-url: ${OPENSHIFT_US_TEST_API_URL:https://api.dev.ocp.example.com:6443} - token: ${OPENSHIFT_US_TEST_TOKEN:your-dev-token-here} - namespace: ${OPENSHIFT_US_TEST_NAMESPACE:devstack-dev} - connection-timeout: 30000 - read-timeout: 30000 - trust-all-certificates: ${OPENSHIFT_US_TEST_TRUST_ALL:true} - - # Test OpenShift instance - test: - api-url: ${OPENSHIFT_EU_DEV_API_URL:https://api.test.ocp.example.com:6443} - token: ${OPENSHIFT_EU_DEV_TOKEN:your-test-token-here} - namespace: ${OPENSHIFT_EU_DEV_NAMESPACE:devstack-test} - connection-timeout: 30000 - read-timeout: 30000 - trust-all-certificates: ${OPENSHIFT_EU_DEV_TRUST_ALL:true} - - bitbucket: - instances: - # Development Bitbucket instance - dev: - base-url: ${BITBUCKET_DEV_BASE_REST_URL:https://bitbucket.dev.example.com} - bearer-token: ${BITBUCKET_DEV_BEARER_TOKEN:} - # OR use basic auth if bearer token is not available: - # username: ${BITBUCKET_DEV_USERNAME:admin} - # password: ${BITBUCKET_DEV_PASSWORD:your-dev-password-here} - connection-timeout: 30000 - read-timeout: 30000 - trust-all-certificates: ${BITBUCKET_DEV_TRUST_ALL:true} - - # Production Bitbucket instance - prod: - base-url: ${BITBUCKET_PROD_BASE_REST_URL:https://bitbucket.prod.example.com} - bearer-token: ${BITBUCKET_PROD_BEARER_TOKEN:} - # OR use basic auth: - # username: ${BITBUCKET_PROD_USERNAME:admin} - # password: ${BITBUCKET_PROD_PASSWORD:your-prod-password-here} - connection-timeout: 30000 - read-timeout: 30000 - trust-all-certificates: ${BITBUCKET_PROD_TRUST_ALL:false} - - webhook-proxy: - clusters: - # Test Cluster - test: - cluster-base: ${WEBHOOK_PROXY_TEST_CLUSTER_BASE:apps.cluster.ocp.com} - connection-timeout: ${WEBHOOK_PROXY_TEST_CONNECTION_TIMEOUT:30000} - read-timeout: ${WEBHOOK_PROXY_TEST_READ_TIMEOUT:30000} - trust-all-certificates: ${WEBHOOK_PROXY_TEST_TRUST_ALL:false} - default-jenkinsfile-path: ${WEBHOOK_PROXY_TEST_JENKINSFILE_PATH:Jenkinsfile} - - projects-info-service: - base-url: ${PROJECTS_INFO_SERVICE_BASE_URL:http://localhost:8081} \ No newline at end of file diff --git a/core/pom.xml b/core/pom.xml index 76bb484..977143e 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 core @@ -107,6 +107,12 @@ ${project.version} + + org.opendevstack.apiservice + api-project + ${project.version} + + org.opendevstack.apiservice api-project-platform @@ -145,6 +151,7 @@ org.opendevstack.apiservice.core.DevstackApiServiceApplication ../docker + ${project.parent.basedir} diff --git a/external-service-aap/pom.xml b/external-service-aap/pom.xml index 8250a95..0c80ffe 100644 --- a/external-service-aap/pom.xml +++ b/external-service-aap/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 external-service-aap diff --git a/external-service-api/pom.xml b/external-service-api/pom.xml index af29c09..b695737 100644 --- a/external-service-api/pom.xml +++ b/external-service-api/pom.xml @@ -7,7 +7,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2-SNAPSHOT + 0.0.3 external-service-api diff --git a/external-service-bitbucket/pom.xml b/external-service-bitbucket/pom.xml index efa5d4b..39467b7 100644 --- a/external-service-bitbucket/pom.xml +++ b/external-service-bitbucket/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 external-service-bitbucket diff --git a/external-service-jira/pom.xml b/external-service-jira/pom.xml index c1ef1bd..f89d456 100644 --- a/external-service-jira/pom.xml +++ b/external-service-jira/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2-SNAPSHOT + 0.0.3 external-service-jira diff --git a/external-service-ocp/pom.xml b/external-service-ocp/pom.xml index a2f7df9..befcf6f 100644 --- a/external-service-ocp/pom.xml +++ b/external-service-ocp/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 external-service-ocp diff --git a/external-service-projects-info-service/pom.xml b/external-service-projects-info-service/pom.xml index d444710..76a7779 100644 --- a/external-service-projects-info-service/pom.xml +++ b/external-service-projects-info-service/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 external-service-projects-info-service diff --git a/external-service-uipath/pom.xml b/external-service-uipath/pom.xml index aa7962c..b2a9925 100644 --- a/external-service-uipath/pom.xml +++ b/external-service-uipath/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 external-service-uipath diff --git a/external-service-webhookproxy/pom.xml b/external-service-webhookproxy/pom.xml index d4ed9a8..94c8ec1 100644 --- a/external-service-webhookproxy/pom.xml +++ b/external-service-webhookproxy/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 external-service-webhookproxy diff --git a/pom.xml b/pom.xml index 605d1ef..572d096 100644 --- a/pom.xml +++ b/pom.xml @@ -14,7 +14,7 @@ org.opendevstack.apiservice devstack-api-service Devstack API Service - 0.0.2 + 0.0.3 21 @@ -52,8 +52,10 @@ external-service-bitbucket external-service-jira external-service-webhookproxy + service-projects api-project-users api-project-platform + api-project @@ -173,8 +175,7 @@ **/*Test.java - - ../application.yaml + ${SPRING_CONFIG_DIR} diff --git a/service-projects/pom.xml b/service-projects/pom.xml new file mode 100644 index 0000000..cd1a931 --- /dev/null +++ b/service-projects/pom.xml @@ -0,0 +1,75 @@ + + 4.0.0 + + + org.opendevstack.apiservice + devstack-api-service + 0.0.3 + + + service-projects + Service Projects + Service module for project operations: key generation and project existence checks + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + + + org.openapitools + jackson-databind-nullable + ${jackson-databind-nullable.version} + + + + org.opendevstack.apiservice + external-service-api + ${project.version} + + + + org.opendevstack.apiservice + external-service-bitbucket + ${project.version} + + + + org.opendevstack.apiservice + external-service-jira + ${project.version} + + + + org.opendevstack.apiservice + external-service-ocp + ${project.version} + + + + org.projectlombok + lombok + provided + + + + org.springframework.boot + spring-boot-starter-test + test + + + + diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectKeyGenerationException.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectKeyGenerationException.java new file mode 100644 index 0000000..0d1fa5f --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectKeyGenerationException.java @@ -0,0 +1,13 @@ +package org.opendevstack.apiservice.serviceproject.exception; + +public class ProjectKeyGenerationException extends Exception { + + public ProjectKeyGenerationException(String message) { + super(message); + } + + public ProjectKeyGenerationException(String message, Throwable cause) { + super(message, cause); + } +} + diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectRequest.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectRequest.java new file mode 100644 index 0000000..22122b6 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectRequest.java @@ -0,0 +1,19 @@ +package org.opendevstack.apiservice.serviceproject.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CreateProjectRequest { + + private String projectKey; + + private String projectKeyPattern; + + private String projectName; + + private String projectDescription; +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectResponse.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectResponse.java new file mode 100644 index 0000000..191a182 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectResponse.java @@ -0,0 +1,23 @@ +package org.opendevstack.apiservice.serviceproject.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CreateProjectResponse { + + private String projectKey; + + private String status; + + private String message; + + private String error; + + private String errorKey; + + private String errorDescription; +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java new file mode 100644 index 0000000..289b977 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java @@ -0,0 +1,11 @@ +package org.opendevstack.apiservice.serviceproject.service; + +import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; + +public interface GenerateProjectKeyService { + + String DEFAULT_PROJECT_KEY_PATTERN = "SS%06d"; + + String generateProjectKey(String projectKeyPattern) throws ProjectKeyGenerationException; +} + diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java new file mode 100644 index 0000000..63f2d66 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.serviceproject.service; + +import org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse; + +public interface ProjectService { + + CreateProjectResponse createProject(CreateProjectRequest request); + + CreateProjectResponse getProject(String projectKey); +} + diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java new file mode 100644 index 0000000..b852443 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java @@ -0,0 +1,144 @@ +package org.opendevstack.apiservice.serviceproject.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.bitbucket.exception.BitbucketException; +import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; +import org.opendevstack.apiservice.externalservice.jira.exception.JiraException; +import org.opendevstack.apiservice.externalservice.jira.service.JiraService; +import org.opendevstack.apiservice.externalservice.ocp.exception.OpenshiftException; +import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; +import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Comparator; +import java.util.Random; +import java.util.Set; + +@Service +@Slf4j +public class GenerateProjectKeyServiceImpl implements GenerateProjectKeyService { + + private static final int MAX_RETRIES = 10; + + private final OpenshiftService openshiftService; + + private final BitbucketService bitbucketService; + + private final JiraService jiraService; + + private final Random random; + + @Autowired + public GenerateProjectKeyServiceImpl(BitbucketService bitbucketService, JiraService jiraService, + OpenshiftService openshiftService) { + this(bitbucketService, jiraService, openshiftService, new Random()); + } + + GenerateProjectKeyServiceImpl(BitbucketService bitbucketService, JiraService jiraService, + OpenshiftService openshiftService, Random random) { + this.bitbucketService = bitbucketService; + this.jiraService = jiraService; + this.openshiftService = openshiftService; + this.random = random; + } + + @Override + public String generateProjectKey(String projectKeyPattern) throws ProjectKeyGenerationException { + String pattern = resolveProjectKeyPattern(projectKeyPattern); + + for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) { + int randomNumber = random.nextInt(1_000_000); + String projectKey = String.format(pattern, randomNumber); + + if (!isProjectFound(projectKey)) { + log.debug("Generated unique project key '{}' on attempt {}", projectKey, attempt); + return projectKey; + } + + log.debug("Project key '{}' already exists (attempt {}/{})", projectKey, attempt, MAX_RETRIES); + } + + throw new ProjectKeyGenerationException( + String.format("Failed to generate unique project key after %d retries", MAX_RETRIES)); + } + + private String resolveProjectKeyPattern(String projectKeyPattern) { + if (projectKeyPattern == null || projectKeyPattern.isBlank()) { + return DEFAULT_PROJECT_KEY_PATTERN; + } + return projectKeyPattern; + } + + private boolean isProjectFound(String projectKey) throws ProjectKeyGenerationException { + try { + if (existsInAnyBitbucketInstance(projectKey)) { + return true; + } + + if (existsInAnyJiraInstance(projectKey)) { + return true; + } + + if (existsInAnyOpenshift(projectKey)) { + return true; + } + + return false; + } catch (BitbucketException e) { + throw new ProjectKeyGenerationException( + String.format("Failed to check project '%s' in Bitbucket", projectKey), e); + } catch (JiraException e) { + throw new ProjectKeyGenerationException( + String.format("Failed to check project '%s' in Jira", projectKey), e); + } catch (OpenshiftException e) { + throw new ProjectKeyGenerationException( + String.format("Failed to check project '%s' in Openshift", projectKey), e); + } + } + + private boolean existsInAnyBitbucketInstance(String projectKey) throws BitbucketException { + Set instances = bitbucketService.getAvailableInstances(); + + for (String instanceName : instances.stream().sorted(Comparator.naturalOrder()).toList()) { + if (bitbucketService.projectExists(instanceName, projectKey)) { + return true; + } + } + + return false; + } + + private boolean existsInAnyJiraInstance(String projectKey) throws JiraException { + Set instances = jiraService.getAvailableInstances(); + + if (instances == null || instances.isEmpty()) { + return jiraService.projectExists(projectKey); + } + + for (String instanceName : instances.stream().sorted(Comparator.naturalOrder()).toList()) { + if (jiraService.projectExists(instanceName, projectKey)) { + return true; + } + } + + return false; + } + + private boolean existsInAnyOpenshift(String projectKey) throws OpenshiftException { + Set instances = openshiftService.getAvailableInstances(); + + if (instances == null || instances.isEmpty()) { + return false; + } + + for (String instanceName : instances.stream().sorted(Comparator.naturalOrder()).toList()) { + if (openshiftService.projectExists(instanceName, projectKey)) { + return true; + } + } + + return false; + } +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java new file mode 100644 index 0000000..c019f5a --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java @@ -0,0 +1,48 @@ +package org.opendevstack.apiservice.serviceproject.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; +import org.opendevstack.apiservice.externalservice.jira.service.JiraService; +import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; +import org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse; +import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +public class ProjectServiceImpl implements ProjectService { + + private final OpenshiftService openshiftService; + + private final BitbucketService bitbucketService; + + private final JiraService jiraService; + + private final GenerateProjectKeyService generateProjectKeyService; + + @Autowired + public ProjectServiceImpl(BitbucketService bitbucketService, JiraService jiraService, + OpenshiftService openshiftService, + GenerateProjectKeyService generateProjectKeyService) { + this.bitbucketService = bitbucketService; + this.jiraService = jiraService; + this.openshiftService = openshiftService; + this.generateProjectKeyService = generateProjectKeyService; + } + + @Override + public CreateProjectResponse createProject(CreateProjectRequest request) { + // TODO Implement project creation against external systems. + return null; + } + + @Override + public CreateProjectResponse getProject(String projectKey) { + // TODO Implement project retrieval by key from external systems. + return null; + } +} + diff --git a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java new file mode 100644 index 0000000..ebba523 --- /dev/null +++ b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java @@ -0,0 +1,98 @@ +package org.opendevstack.apiservice.serviceproject.service.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; +import org.opendevstack.apiservice.externalservice.jira.service.JiraService; +import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; +import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; + +import java.util.Random; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class GenerateProjectKeyServiceImplTest { + + @Mock + private BitbucketService bitbucketService; + + @Mock + private JiraService jiraService; + + @Mock + private OpenshiftService openshiftService; + + @Mock + private Random random; + + private GenerateProjectKeyServiceImpl tested; + + @BeforeEach + void setup() { + tested = new GenerateProjectKeyServiceImpl(bitbucketService, jiraService, openshiftService, random); + when(bitbucketService.getAvailableInstances()).thenReturn(Set.of("dev")); + when(jiraService.getAvailableInstances()).thenReturn(Set.of("default")); + when(openshiftService.getAvailableInstances()).thenReturn(Set.of()); + } + + @Test + void generateProjectKey_whenFirstCandidateIsFree_thenReturnKey() throws Exception { + when(random.nextInt(1_000_000)).thenReturn(7); + when(bitbucketService.projectExists(anyString(), anyString())).thenReturn(false); + when(jiraService.projectExists(anyString(), anyString())).thenReturn(false); + + String result = tested.generateProjectKey(null); + + assertThat(result).isEqualTo("SS000007"); + } + + @Test + void generateProjectKey_whenFirstCandidateExists_thenRetryUntilUnique() throws Exception { + when(random.nextInt(1_000_000)).thenReturn(1, 2); + + when(bitbucketService.projectExists("dev", "SS000001")).thenReturn(true); + when(jiraService.projectExists("default", "SS000001")).thenReturn(false); + + when(bitbucketService.projectExists("dev", "SS000002")).thenReturn(false); + when(jiraService.projectExists("default", "SS000002")).thenReturn(false); + + String result = tested.generateProjectKey("SS%06d"); + + assertThat(result).isEqualTo("SS000002"); + } + + @Test + void generateProjectKey_whenNoUniqueKeyAfterMaxRetries_thenThrowException() throws Exception { + when(random.nextInt(1_000_000)).thenReturn(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11); + + when(bitbucketService.projectExists(anyString(), anyString())).thenReturn(false); + + when(jiraService.projectExists(anyString(), anyString())).thenReturn(true); + + assertThatThrownBy(() -> tested.generateProjectKey("SS%06d")) + .isInstanceOf(ProjectKeyGenerationException.class) + .hasMessageContaining("Failed to generate unique project key after 10 retries"); + } + + @Test + void generateProjectKey_whenCustomPatternProvided_thenUseIt() throws Exception { + when(random.nextInt(1_000_000)).thenReturn(42); + when(bitbucketService.projectExists(anyString(), anyString())).thenReturn(false); + when(jiraService.projectExists(anyString(), anyString())).thenReturn(false); + + String result = tested.generateProjectKey("AB%04d"); + + assertThat(result).isEqualTo("AB0042"); + } +} diff --git a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java new file mode 100644 index 0000000..0779419 --- /dev/null +++ b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java @@ -0,0 +1,34 @@ +package org.opendevstack.apiservice.serviceproject.service.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; +import org.opendevstack.apiservice.externalservice.jira.service.JiraService; +import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; +import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; + +@ExtendWith(MockitoExtension.class) +class ProjectServiceImplTest { + + @Mock + private BitbucketService bitbucketService; + + @Mock + private JiraService jiraService; + + @Mock + private OpenshiftService openshiftService; + + @Mock + private GenerateProjectKeyService generateProjectKeyService; + + private ProjectServiceImpl sut; + + @BeforeEach + void setup() { + sut = new ProjectServiceImpl(bitbucketService, jiraService, openshiftService, generateProjectKeyService); + } +} +