diff --git a/.gitignore b/.gitignore
index f766354..5183737 100644
--- a/.gitignore
+++ b/.gitignore
@@ -93,6 +93,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 6c7df06..df729a5 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-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/openapi/api-project.yaml b/api-project/openapi/api-project.yaml
new file mode 100644
index 0000000..8fbd57b
--- /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/pub/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..a17d338
--- /dev/null
+++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java
@@ -0,0 +1,63 @@
+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(ProjectController.API_BASE_PATH)
+@AllArgsConstructor
+@Slf4j
+public class ProjectController implements ProjectsApi {
+
+ public static final String API_BASE_PATH = "/api/pub/v0/projects";
+
+ 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());
+ return ResponseEntity.status(HttpStatus.CONFLICT)
+ .body(ProjectResponseFactory.conflict(e.getMessage()));
+ } catch (ProjectKeyGenerationException e) {
+ log.error("Failed to generate project key: {}", e.getMessage(), e);
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body(ProjectResponseFactory.projectKeyGenerationFailed());
+ }
+ }
+
+ @GetMapping("/{projectKey}")
+ @Override
+ public ResponseEntity getProject(@PathVariable String projectKey) {
+ try {
+ CreateProjectResponse response = projectsFacade.getProject(projectKey);
+ if (response == null) {
+ return ResponseEntity.status(HttpStatus.NOT_FOUND)
+ .body(ProjectResponseFactory.notFound(projectKey));
+ }
+ return ResponseEntity.ok(response);
+ } catch (ProjectCreationException e) {
+ log.error("Error retrieving project '{}': {}", projectKey, e.getMessage(), e);
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body(ProjectResponseFactory.internalError());
+ }
+ }
+}
diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java
new file mode 100644
index 0000000..6a6faae
--- /dev/null
+++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java
@@ -0,0 +1,38 @@
+package org.opendevstack.apiservice.project.controller;
+
+import org.opendevstack.apiservice.project.model.CreateProjectResponse;
+
+public final class ProjectResponseFactory {
+
+ private static final String INTERNAL_ERROR = "INTERNAL_ERROR";
+
+ private ProjectResponseFactory() {
+ }
+
+ public static CreateProjectResponse conflict(String message) {
+ return error("CONFLICT", "PROJECT_ALREADY_EXISTS", message);
+ }
+
+ public static CreateProjectResponse projectKeyGenerationFailed() {
+ return error(INTERNAL_ERROR, "PROJECT_KEY_GENERATION_FAILED",
+ "Failed to generate a unique project key.");
+ }
+
+ public static CreateProjectResponse notFound(String projectKey) {
+ return error("NOT_FOUND", "PROJECT_NOT_FOUND",
+ String.format("Project with key '%s' not found", projectKey));
+ }
+
+ public static CreateProjectResponse internalError() {
+ return error(INTERNAL_ERROR, INTERNAL_ERROR,
+ "An error occurred while processing the request.");
+ }
+
+ private static CreateProjectResponse error(String error, String errorKey, String message) {
+ CreateProjectResponse response = new CreateProjectResponse();
+ response.setError(error);
+ response.setErrorKey(errorKey);
+ response.setMessage(message);
+ return response;
+ }
+}
\ No newline at end of file
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/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/core/pom.xml b/core/pom.xml
index 09a9ad9..6705b44 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -107,6 +107,12 @@
${project.version}
+
+ org.opendevstack.apiservice
+ api-project
+ ${project.version}
+
+
org.opendevstack.apiservice
api-project-platform
@@ -159,6 +165,7 @@
org.opendevstack.apiservice.core.DevstackApiServiceApplication
../docker
+ ${project.parent.basedir}
diff --git a/pom.xml b/pom.xml
index 4248e13..c825aa0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -53,8 +53,10 @@
external-service-bitbucket
external-service-jira
external-service-webhookproxy
+ service-projects
api-project-users
api-project-platform
+ api-project
@@ -173,6 +175,9 @@
**/*Test.java
+
+ ${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..1dcd607
--- /dev/null
+++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectResponse.java
@@ -0,0 +1,25 @@
+package org.opendevstack.apiservice.serviceproject.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+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..2e53781
--- /dev/null
+++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java
@@ -0,0 +1,141 @@
+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.security.SecureRandom;
+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 SecureRandom());
+ }
+
+ 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;
+ }
+
+ return existsInAnyOpenshift(projectKey);
+ } 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..3e8caab
--- /dev/null
+++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java
@@ -0,0 +1,37 @@
+package org.opendevstack.apiservice.serviceproject.service.impl;
+
+import lombok.AllArgsConstructor;
+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.stereotype.Service;
+
+@Service
+@Slf4j
+@AllArgsConstructor
+public class ProjectServiceImpl implements ProjectService {
+
+ private final OpenshiftService openshiftService;
+
+ private final BitbucketService bitbucketService;
+
+ private final JiraService jiraService;
+
+ private final GenerateProjectKeyService generateProjectKeyService;
+
+ @Override
+ public CreateProjectResponse createProject(CreateProjectRequest request) {
+ return CreateProjectResponse.builder().build();
+ }
+
+ @Override
+ public CreateProjectResponse getProject(String projectKey) {
+ return CreateProjectResponse.builder().build();
+ }
+}
+
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");
+ }
+}