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
+
+ 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);
+ }
+}
+