diff --git a/.gitignore b/.gitignore index 46a7edea..b254917f 100644 --- a/.gitignore +++ b/.gitignore @@ -95,6 +95,8 @@ api-project-platform/src/main/java/org/opendevstack/apiservice/projectplatform/a api-project-platform/src/main/java/org/opendevstack/apiservice/projectplatform/model api-project/src/main/java/org/opendevstack/apiservice/project/api api-project/src/main/java/org/opendevstack/apiservice/project/model +api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/api +api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/model api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/api api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/model external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/openapi diff --git a/api-project-v1/openapi/api-project-v1.yaml b/api-project-v1/openapi/api-project-v1.yaml new file mode 100644 index 00000000..9a472e44 --- /dev/null +++ b/api-project-v1/openapi/api-project-v1.yaml @@ -0,0 +1,126 @@ +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/v1 + variables: + baseurl: + default: localhost:8080 + description: Development environment +tags: +- name: Projects + description: API for manage EDP projects. +paths: + /projects: + get: + tags: + - Projects + summary: Get all projects filtered by page and size. + description: Returns the current status and details for each project in a list filtered by the given page and size. + operationId: getProjects + parameters: + - name: page + in: query + required: false + schema: + type: integer + default: 0 + minimum: 0 + description: Page number to retrieve. + - name: size + in: query + required: false + schema: + type: integer + default: 20 + minimum: 20 + maximum: 1000 + description: Size of the page to retrieve. + responses: + '200': + description: Project information retrieved successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/GetProjectsResponse' + '404': + description: Project not found. + content: + application/json: + schema: + $ref: '#/components/schemas/GetProjectsResponse' + "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/GetProjectsResponse' +components: + schemas: + RestErrorMessage: + properties: + message: + type: string + required: + - message + ProjectsResponse: + type: object + properties: + projectKey: + type: string + projectName: + type: string + projectFlavor: + type: string + location: + type: string + status: + type: string + createdAt: + type: string + updatedAt: + type: string + GetProjectsResponse: + type: object + properties: + projects: + type: array + items: + $ref: '#/components/schemas/ProjectsResponse' + metadata: + type: object + properties: + page: + type: integer + size: + type: integer + totalElements: + type: integer + totalPages: + type: integer + last: + type: boolean + message: + type: string + error: + type: string + errorKey: + type: string + location: + type: string \ No newline at end of file diff --git a/api-project-v1/pom.xml b/api-project-v1/pom.xml new file mode 100644 index 00000000..e3a18fba --- /dev/null +++ b/api-project-v1/pom.xml @@ -0,0 +1,182 @@ + + 4.0.0 + + + org.opendevstack.apiservice + devstack-api-service + 0.0.3 + + + api-project-v1 + API Projects V1 + 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.springframework.boot + spring-boot-devtools + runtime + true + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + + + org.opendevstack.apiservice + service-projects + ${project.version} + + + + org.opendevstack.apiservice + persistence + ${project.version} + + + + org.opendevstack.apiservice + core-security + ${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 + ${mapstruct.version} + + + + 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 + ${mapstruct.version} + + + + + + org.openapitools + openapi-generator-maven-plugin + + + generate-api-project-v1 + + generate + + + spring + ${project.basedir}/target/generated-sources/openapi + spring-boot + ${project.basedir}/openapi/api-project-v1.yaml + org.opendevstack.apiservice.projectv1.client.api + org.opendevstack.apiservice.projectv1.client.model + org.opendevstack.apiservice.projectv1.client + 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-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/ProjectController.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/ProjectController.java new file mode 100644 index 00000000..68387325 --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/ProjectController.java @@ -0,0 +1,37 @@ +package org.opendevstack.apiservice.projectv1.controller; + +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.projectv1.client.api.ProjectsApi; +import org.opendevstack.apiservice.projectv1.facade.ProjectsFacade; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController("projectControllerV1") +@RequestMapping(ProjectController.API_BASE_PATH) +@AllArgsConstructor +@Slf4j +public class ProjectController implements ProjectsApi { + + public static final String API_BASE_PATH = "/api/pub/v1/projects"; + + private final ProjectsFacade projectsFacade; + + @GetMapping + @Override + public ResponseEntity getProjects(Integer page, Integer size) { + + GetProjectsResponse response = projectsFacade.getProjects(page, size); + response.setLocation(API_BASE_PATH); + + return ResponseEntity + .status(HttpStatus.OK) + .body(response); + } +} diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandler.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandler.java new file mode 100644 index 00000000..ae75cc81 --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandler.java @@ -0,0 +1,78 @@ +package org.opendevstack.apiservice.projectv1.controller.advice; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Path; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.projectv1.controller.ProjectController; +import org.opendevstack.apiservice.projectv1.exception.ErrorKey; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; +import java.util.Optional; + +@Component("projectExceptionHandlerV1") +@RestControllerAdvice(assignableTypes = ProjectController.class) +@Slf4j +public class ProjectExceptionHandler { + + private static final Map FIELD_ERROR_MAP = Map.of( + "page", ErrorKey.INVALID_PAGE, + "size", ErrorKey.INVALID_SIZE + ); + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException( + ConstraintViolationException ex) { + log.warn("Request validation error: {}", ex.getMessage()); + + Optional error = ex.getConstraintViolations() + .stream() + .map(this::formatConstraintViolation) + .findFirst(); + + GetProjectsResponse response = new GetProjectsResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(HttpStatus.BAD_REQUEST.getReasonPhrase()); + + if (error.isPresent()) { + ErrorKey key = error.get(); + response.setErrorKey(key.getKey()); + response.setMessage(key.getMessage()); + } else { + response.setErrorKey(ErrorKey.BAD_REQUEST_BODY.getKey()); + response.setMessage(ErrorKey.BAD_REQUEST_BODY.getMessage()); + } + + return ResponseEntity.badRequest().body(response); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception ex) { + log.error("Unexpected error: {}", ex.getMessage(), ex); + GetProjectsResponse response = new GetProjectsResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(ErrorKey.INTERNAL_ERROR.getMessage()); + response.setErrorKey(ErrorKey.INTERNAL_ERROR.getKey()); + response.setMessage("An error occurred while processing the request."); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + + private ErrorKey formatConstraintViolation(ConstraintViolation violation) { + Path path = violation.getPropertyPath(); + + String lastNodeName = null; + for (Path.Node node : path) { + lastNodeName = node.getName(); + } + + return FIELD_ERROR_MAP.getOrDefault(lastNodeName, ErrorKey.BAD_REQUEST_BODY); + } +} \ No newline at end of file diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorKey.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorKey.java new file mode 100644 index 00000000..c0691b1e --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorKey.java @@ -0,0 +1,25 @@ +package org.opendevstack.apiservice.projectv1.exception; + +public enum ErrorKey { + + INTERNAL_ERROR("003", ErrorMessage.INTERNAL_ERROR), + BAD_REQUEST_BODY("014", ErrorMessage.BAD_REQUEST), + INVALID_PAGE("031", ErrorMessage.INVALID_PAGE), + INVALID_SIZE("032", ErrorMessage.INVALID_SIZE); + + private String key; + private String message; + + ErrorKey(String key, String message) { + this.key = key; + this.message = message; + } + + public String getKey() { + return this.key; + } + + public String getMessage() { + return this.message; + } +} diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorMessage.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorMessage.java new file mode 100644 index 00000000..ce982c47 --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorMessage.java @@ -0,0 +1,14 @@ +package org.opendevstack.apiservice.projectv1.exception; + +public class ErrorMessage { + + public static final String BAD_REQUEST = "Bad Request"; + public static final String INTERNAL_ERROR = "Internal error"; + + public static final String INVALID_PAGE = "page must be an integer greater or equal to 0"; + public static final String INVALID_SIZE = "size must be an integer greater or equal to 20"; + + private ErrorMessage() { + // prevent instantiation + } +} diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/ProjectsFacade.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/ProjectsFacade.java new file mode 100644 index 00000000..5362c752 --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/ProjectsFacade.java @@ -0,0 +1,8 @@ +package org.opendevstack.apiservice.projectv1.facade; + +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; + +public interface ProjectsFacade { + + GetProjectsResponse getProjects(Integer page, Integer size); +} diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImpl.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImpl.java new file mode 100644 index 00000000..e4ebd6b1 --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImpl.java @@ -0,0 +1,24 @@ +package org.opendevstack.apiservice.projectv1.facade.impl; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.projectv1.facade.ProjectsFacade; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.projectv1.mapper.ProjectMapper; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.stereotype.Component; + +@Component("apiProjectFacadeImplV1") +@AllArgsConstructor +@Slf4j +public class ProjectsFacadeImpl implements ProjectsFacade { + + private final ProjectService projectService; + + private final ProjectMapper projectMapper; + + @Override + public GetProjectsResponse getProjects(Integer page, Integer size) { + return projectMapper.toApiResponse(projectService.getProjects(), page, size); + } +} diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapper.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapper.java new file mode 100644 index 00000000..9ce4cd88 --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapper.java @@ -0,0 +1,62 @@ +package org.opendevstack.apiservice.projectv1.mapper; + +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponseMetadata; +import org.opendevstack.apiservice.projectv1.client.model.ProjectsResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectSummary; +import org.springframework.stereotype.Component; + +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Component("projectMapperV1") +public class ProjectMapper { + + private static final int DEFAULT_PAGE = 0; + private static final int DEFAULT_SIZE = 20; + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + + public GetProjectsResponse toApiResponse(List projects, Integer page, Integer size) { + int effectivePage = page != null ? page : DEFAULT_PAGE; + int effectiveSize = size != null && size > 0 ? size : DEFAULT_SIZE; + + int totalElements = projects != null ? projects.size() : 0; + int totalPages = (int) Math.ceil((double) totalElements / effectiveSize); + boolean isLast = (effectivePage + 1) >= totalPages; + + List pageContent = projects == null + ? List.of() + : projects.stream() + .skip((long) effectivePage * effectiveSize) + .limit(effectiveSize) + .map(this::toProjectsResponse) + .toList(); + + GetProjectsResponseMetadata metadata = new GetProjectsResponseMetadata() + .page(effectivePage) + .size(effectiveSize) + .totalElements(totalElements) + .totalPages(totalPages) + .last(isLast); + + return new GetProjectsResponse() + .projects(pageContent) + .metadata(metadata); + } + + private ProjectsResponse toProjectsResponse(ProjectSummary source) { + return new ProjectsResponse() + .projectKey(source.getProjectKey()) + .projectName(source.getProjectName()) + .projectFlavor(source.getProjectFlavor()) + .location(source.getLocation()) + .status(source.getStatus() != null ? source.getStatus().name() : null) + .createdAt(formatDateTime(source.getCreatedAt())) + .updatedAt(formatDateTime(source.getUpdatedAt())); + } + + private String formatDateTime(OffsetDateTime dateTime) { + return dateTime != null ? dateTime.format(FORMATTER) : null; + } +} diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/ProjectControllerTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/ProjectControllerTest.java new file mode 100644 index 00000000..ecd3c2e1 --- /dev/null +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/ProjectControllerTest.java @@ -0,0 +1,109 @@ +package org.opendevstack.apiservice.projectv1.controller; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponseMetadata; +import org.opendevstack.apiservice.projectv1.client.model.ProjectsResponse; +import org.opendevstack.apiservice.projectv1.facade.ProjectsFacade; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProjectControllerTest { + + @Mock + private ProjectsFacade projectsFacade; + + private ProjectController sut; + private AutoCloseable mocks; + + @BeforeEach + void setup() { + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectController(projectsFacade); + + Jwt jwtToken = Jwt.withTokenValue("dummy-token") + .claim("appid", UUID.randomUUID().toString()) + .claim("sub", "test-user") + .header("alg", "none") + .build(); + JwtAuthenticationToken authentication = new JwtAuthenticationToken(jwtToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + @AfterEach + void tearDown() throws Exception { + mocks.close(); + } + + @Test + void get_projects_returns_ok_with_projects_list() { + ProjectsResponse project = new ProjectsResponse() + .projectKey("PROJ01") + .projectName("Project One") + .projectFlavor("AMP") + .location("eu") + .status("RUNNING"); + + GetProjectsResponseMetadata metadata = new GetProjectsResponseMetadata() + .page(0) + .size(20) + .totalElements(1) + .totalPages(1) + .last(true); + + GetProjectsResponse facadeResponse = new GetProjectsResponse() + .projects(List.of(project)) + .metadata(metadata); + + when(projectsFacade.getProjects(0, 20)).thenReturn(facadeResponse); + + ResponseEntity result = sut.getProjects(0, 20); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getProjects()).hasSize(1); + assertThat(result.getBody().getProjects().get(0).getProjectKey()).isEqualTo("PROJ01"); + assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); + assertThat(result.getBody().getMetadata()).isNotNull(); + assertThat(result.getBody().getMetadata().getTotalElements()).isEqualTo(1); + verify(projectsFacade).getProjects(0, 20); + } + + @Test + void get_projects_returns_ok_with_empty_list_when_no_projects_exist() { + GetProjectsResponseMetadata metadata = new GetProjectsResponseMetadata() + .page(0) + .size(20) + .totalElements(0) + .totalPages(0) + .last(true); + + GetProjectsResponse facadeResponse = new GetProjectsResponse() + .projects(List.of()) + .metadata(metadata); + + when(projectsFacade.getProjects(0, 20)).thenReturn(facadeResponse); + + ResponseEntity result = sut.getProjects(0, 20); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getProjects()).isEmpty(); + assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); + verify(projectsFacade).getProjects(0, 20); + } +} diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandlerTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandlerTest.java new file mode 100644 index 00000000..caa5099e --- /dev/null +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandlerTest.java @@ -0,0 +1,100 @@ +package org.opendevstack.apiservice.projectv1.controller.advice; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Path; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.projectv1.controller.ProjectController; +import org.opendevstack.apiservice.projectv1.exception.ErrorKey; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ProjectExceptionHandlerTest { + + private ProjectExceptionHandler sut; + + @BeforeEach + void setUp() { + sut = new ProjectExceptionHandler(); + } + + @Test + void handle_constraint_violation_returns_bad_request_for_invalid_page() { + ConstraintViolationException ex = createConstraintViolationException("page"); + + ResponseEntity result = sut.handleConstraintViolationException(ex); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getError()).isEqualTo(HttpStatus.BAD_REQUEST.getReasonPhrase()); + assertThat(result.getBody().getErrorKey()).isEqualTo(ErrorKey.INVALID_PAGE.getKey()); + assertThat(result.getBody().getMessage()).isEqualTo(ErrorKey.INVALID_PAGE.getMessage()); + assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); + } + + @Test + void handle_constraint_violation_returns_bad_request_for_invalid_size() { + ConstraintViolationException ex = createConstraintViolationException("size"); + + ResponseEntity result = sut.handleConstraintViolationException(ex); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getError()).isEqualTo(HttpStatus.BAD_REQUEST.getReasonPhrase()); + assertThat(result.getBody().getErrorKey()).isEqualTo(ErrorKey.INVALID_SIZE.getKey()); + assertThat(result.getBody().getMessage()).isEqualTo(ErrorKey.INVALID_SIZE.getMessage()); + assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); + } + + @Test + void handle_constraint_violation_returns_bad_request_for_unknown_field() { + ConstraintViolationException ex = createConstraintViolationException("unknownField"); + + ResponseEntity result = sut.handleConstraintViolationException(ex); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getError()).isEqualTo(HttpStatus.BAD_REQUEST.getReasonPhrase()); + assertThat(result.getBody().getErrorKey()).isEqualTo(ErrorKey.BAD_REQUEST_BODY.getKey()); + assertThat(result.getBody().getMessage()).isEqualTo(ErrorKey.BAD_REQUEST_BODY.getMessage()); + assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); + } + + @Test + void handle_generic_exception_returns_internal_server_error() { + Exception ex = new RuntimeException("Unexpected database failure"); + + ResponseEntity result = sut.handleGenericException(ex); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getError()).isEqualTo(ErrorKey.INTERNAL_ERROR.getMessage()); + assertThat(result.getBody().getErrorKey()).isEqualTo(ErrorKey.INTERNAL_ERROR.getKey()); + assertThat(result.getBody().getMessage()).isEqualTo("An error occurred while processing the request."); + assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); + } + + // ------- helpers ------- + + private ConstraintViolationException createConstraintViolationException(String fieldName) { + Path.Node node = mock(Path.Node.class); + when(node.getName()).thenReturn(fieldName); + + Path path = mock(Path.class); + when(path.iterator()).thenReturn(List.of(node).iterator()); + + ConstraintViolation violation = mock(ConstraintViolation.class); + when(violation.getPropertyPath()).thenReturn(path); + + return new ConstraintViolationException(Set.of(violation)); + } +} diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImplTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImplTest.java new file mode 100644 index 00000000..eb69dc4e --- /dev/null +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImplTest.java @@ -0,0 +1,138 @@ +package org.opendevstack.apiservice.projectv1.facade.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponseMetadata; +import org.opendevstack.apiservice.projectv1.client.model.ProjectsResponse; +import org.opendevstack.apiservice.projectv1.mapper.ProjectMapper; +import org.opendevstack.apiservice.serviceproject.model.ProjectSummary; +import org.opendevstack.apiservice.serviceproject.model.Status; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; + +import java.time.OffsetDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProjectsFacadeImplTest { + + @Mock + private ProjectService projectService; + + @Mock + private ProjectMapper projectMapper; + + private ProjectsFacadeImpl sut; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + sut = new ProjectsFacadeImpl(projectService, projectMapper); + } + + @Test + void get_projects_returns_response_from_mapper() { + List summaries = List.of( + ProjectSummary.builder() + .projectKey("PROJ01") + .projectName("Project One") + .projectFlavor("AMP") + .location("eu") + .status(Status.RUNNING) + .build() + ); + + GetProjectsResponse expectedResponse = new GetProjectsResponse() + .projects(List.of(new ProjectsResponse().projectKey("PROJ01"))) + .metadata(new GetProjectsResponseMetadata().page(0).size(20).totalElements(1).totalPages(1).last(true)); + + when(projectService.getProjects()).thenReturn(summaries); + when(projectMapper.toApiResponse(summaries, 0, 20)).thenReturn(expectedResponse); + + GetProjectsResponse result = sut.getProjects(0, 20); + + assertThat(result).isNotNull(); + assertThat(result.getProjects()).hasSize(1); + assertThat(result.getProjects().get(0).getProjectKey()).isEqualTo("PROJ01"); + verify(projectService).getProjects(); + verify(projectMapper).toApiResponse(summaries, 0, 20); + } + + @Test + void get_projects_returns_empty_list_when_no_projects() { + List emptySummaries = List.of(); + + GetProjectsResponse emptyResponse = new GetProjectsResponse() + .projects(List.of()) + .metadata(new GetProjectsResponseMetadata().page(0).size(20).totalElements(0).totalPages(0).last(true)); + + when(projectService.getProjects()).thenReturn(emptySummaries); + when(projectMapper.toApiResponse(emptySummaries, 0, 20)).thenReturn(emptyResponse); + + GetProjectsResponse result = sut.getProjects(0, 20); + + assertThat(result).isNotNull(); + assertThat(result.getProjects()).isEmpty(); + assertThat(result.getMetadata().getTotalElements()).isEqualTo(0); + verify(projectService).getProjects(); + verify(projectMapper).toApiResponse(emptySummaries, 0, 20); + } + + @Test + void get_projects_propagates_service_exception() { + when(projectService.getProjects()).thenThrow(new RuntimeException("DB error")); + + assertThrows(RuntimeException.class, + () -> sut.getProjects(0, 20)); + + verify(projectService).getProjects(); + } + + @Test + void get_projects_returns_multiple_projects_with_all_fields() { + OffsetDateTime now = OffsetDateTime.now(); + + List summaries = List.of( + ProjectSummary.builder() + .projectKey("PROJ01") + .projectName("Project One") + .projectFlavor("AMP") + .location("eu") + .status(Status.RUNNING) + .createdAt(now) + .updatedAt(now) + .build(), + ProjectSummary.builder() + .projectKey("PROJ02") + .projectName("Project Two") + .projectFlavor("DEFAULT") + .location("us") + .status(Status.PENDING) + .createdAt(now) + .updatedAt(now) + .build() + ); + + GetProjectsResponse expectedResponse = new GetProjectsResponse() + .projects(List.of( + new ProjectsResponse().projectKey("PROJ01").status("RUNNING"), + new ProjectsResponse().projectKey("PROJ02").status("PENDING") + )) + .metadata(new GetProjectsResponseMetadata().page(0).size(20).totalElements(2).totalPages(1).last(true)); + + when(projectService.getProjects()).thenReturn(summaries); + when(projectMapper.toApiResponse(summaries, 0, 20)).thenReturn(expectedResponse); + + GetProjectsResponse result = sut.getProjects(0, 20); + + assertThat(result.getProjects()).hasSize(2); + assertThat(result.getMetadata().getTotalElements()).isEqualTo(2); + assertThat(result.getMetadata().getLast()).isTrue(); + } +} diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapperTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapperTest.java new file mode 100644 index 00000000..00c64aeb --- /dev/null +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapperTest.java @@ -0,0 +1,173 @@ +package org.opendevstack.apiservice.projectv1.mapper; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.projectv1.client.model.ProjectsResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectSummary; +import org.opendevstack.apiservice.serviceproject.model.Status; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ProjectMapperTest { + + private ProjectMapper sut; + + @BeforeEach + void setUp() { + sut = new ProjectMapper(); + } + + @Test + void to_api_response_maps_projects_correctly() { + OffsetDateTime createdAt = OffsetDateTime.of(2024, 1, 15, 10, 0, 0, 0, ZoneOffset.UTC); + OffsetDateTime updatedAt = OffsetDateTime.of(2024, 6, 1, 12, 30, 0, 0, ZoneOffset.UTC); + + ProjectSummary summary = ProjectSummary.builder() + .projectKey("PROJ01") + .projectName("Project One") + .projectFlavor("AMP") + .location("eu") + .status(Status.RUNNING) + .createdAt(createdAt) + .updatedAt(updatedAt) + .build(); + + GetProjectsResponse result = sut.toApiResponse(List.of(summary), 0, 20); + + assertThat(result).isNotNull(); + assertThat(result.getProjects()).hasSize(1); + + ProjectsResponse project = result.getProjects().get(0); + assertThat(project.getProjectKey()).isEqualTo("PROJ01"); + assertThat(project.getProjectName()).isEqualTo("Project One"); + assertThat(project.getProjectFlavor()).isEqualTo("AMP"); + assertThat(project.getLocation()).isEqualTo("eu"); + assertThat(project.getStatus()).isEqualTo("RUNNING"); + assertThat(project.getCreatedAt()).isNotNull(); + assertThat(project.getUpdatedAt()).isNotNull(); + } + + @Test + void to_api_response_returns_empty_list_when_projects_are_null() { + GetProjectsResponse result = sut.toApiResponse(null, 0, 20); + + assertThat(result).isNotNull(); + assertThat(result.getProjects()).isEmpty(); + assertThat(result.getMetadata().getTotalElements()).isEqualTo(0); + assertThat(result.getMetadata().getTotalPages()).isEqualTo(0); + } + + @Test + void to_api_response_returns_empty_list_when_projects_list_is_empty() { + GetProjectsResponse result = sut.toApiResponse(List.of(), 0, 20); + + assertThat(result).isNotNull(); + assertThat(result.getProjects()).isEmpty(); + assertThat(result.getMetadata().getTotalElements()).isEqualTo(0); + assertThat(result.getMetadata().getTotalPages()).isEqualTo(0); + assertThat(result.getMetadata().getLast()).isTrue(); + } + + @Test + void to_api_response_calculates_metadata_correctly() { + List summaries = List.of( + ProjectSummary.builder().projectKey("P1").build(), + ProjectSummary.builder().projectKey("P2").build(), + ProjectSummary.builder().projectKey("P3").build() + ); + + GetProjectsResponse result = sut.toApiResponse(summaries, 0, 20); + + assertThat(result.getMetadata().getPage()).isEqualTo(0); + assertThat(result.getMetadata().getSize()).isEqualTo(20); + assertThat(result.getMetadata().getTotalElements()).isEqualTo(3); + assertThat(result.getMetadata().getTotalPages()).isEqualTo(1); + assertThat(result.getMetadata().getLast()).isTrue(); + } + + @Test + void to_api_response_paginates_correctly_first_page() { + List summaries = List.of( + ProjectSummary.builder().projectKey("P1").build(), + ProjectSummary.builder().projectKey("P2").build(), + ProjectSummary.builder().projectKey("P3").build(), + ProjectSummary.builder().projectKey("P4").build(), + ProjectSummary.builder().projectKey("P5").build() + ); + + GetProjectsResponse result = sut.toApiResponse(summaries, 0, 2); + + assertThat(result.getProjects()).hasSize(2); + assertThat(result.getProjects().get(0).getProjectKey()).isEqualTo("P1"); + assertThat(result.getProjects().get(1).getProjectKey()).isEqualTo("P2"); + assertThat(result.getMetadata().getTotalElements()).isEqualTo(5); + assertThat(result.getMetadata().getTotalPages()).isEqualTo(3); + assertThat(result.getMetadata().getLast()).isFalse(); + } + + @Test + void to_api_response_paginates_correctly_last_page() { + List summaries = List.of( + ProjectSummary.builder().projectKey("P1").build(), + ProjectSummary.builder().projectKey("P2").build(), + ProjectSummary.builder().projectKey("P3").build(), + ProjectSummary.builder().projectKey("P4").build(), + ProjectSummary.builder().projectKey("P5").build() + ); + + GetProjectsResponse result = sut.toApiResponse(summaries, 2, 2); + + assertThat(result.getProjects()).hasSize(1); + assertThat(result.getProjects().get(0).getProjectKey()).isEqualTo("P5"); + assertThat(result.getMetadata().getLast()).isTrue(); + } + + @Test + void to_api_response_uses_default_page_when_null() { + ProjectSummary summary = ProjectSummary.builder().projectKey("P1").build(); + + GetProjectsResponse result = sut.toApiResponse(List.of(summary), null, 20); + + assertThat(result.getMetadata().getPage()).isEqualTo(0); + } + + @Test + void to_api_response_uses_default_size_when_null() { + ProjectSummary summary = ProjectSummary.builder().projectKey("P1").build(); + + GetProjectsResponse result = sut.toApiResponse(List.of(summary), 0, null); + + assertThat(result.getMetadata().getSize()).isEqualTo(20); + } + + @Test + void to_api_response_maps_null_status_to_null_string() { + ProjectSummary summary = ProjectSummary.builder() + .projectKey("P1") + .status(null) + .build(); + + GetProjectsResponse result = sut.toApiResponse(List.of(summary), 0, 20); + + assertThat(result.getProjects().get(0).getStatus()).isNull(); + } + + @Test + void to_api_response_maps_null_datetime_fields_to_null() { + ProjectSummary summary = ProjectSummary.builder() + .projectKey("P1") + .createdAt(null) + .updatedAt(null) + .build(); + + GetProjectsResponse result = sut.toApiResponse(List.of(summary), 0, 20); + + assertThat(result.getProjects().get(0).getCreatedAt()).isNull(); + assertThat(result.getProjects().get(0).getUpdatedAt()).isNull(); + } +} diff --git a/core/pom.xml b/core/pom.xml index a0ba5310..3c4cd141 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -134,6 +134,12 @@ ${project.version} + + org.opendevstack.apiservice + api-project-v1 + ${project.version} + + org.opendevstack.apiservice api-project-component-v0 diff --git a/pom.xml b/pom.xml index f14bc6df..881bcaf7 100644 --- a/pom.xml +++ b/pom.xml @@ -61,6 +61,7 @@ api-project-users api-project-platform api-project + api-project-v1 api-project-component-v0 diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java index 199a43ba..7a8c1b47 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java @@ -9,6 +9,7 @@ import org.mapstruct.Named; import org.opendevstack.apiservice.persistence.entity.ProjectEntity; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectSummary; import org.opendevstack.apiservice.serviceproject.model.Status; @Mapper(componentModel = "spring") @@ -22,6 +23,13 @@ public interface ProjectResponseMapper { @IterableMapping(qualifiedByName = "mapEntityToResponse") List toCreateProjectResponse(List entities); + @Mapping(source = "status", target = "status", qualifiedByName = "mapStatus") + @Named("mapEntityToSummary") + ProjectSummary toProjectSummary(ProjectEntity entity); + + @IterableMapping(qualifiedByName = "mapEntityToSummary") + List toProjectSummary(List entities); + @Named("mapStatus") default Status mapStatus(String value) { if (value == null) { diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectSummary.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectSummary.java new file mode 100644 index 00000000..3785347d --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectSummary.java @@ -0,0 +1,35 @@ +package org.opendevstack.apiservice.serviceproject.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.OffsetDateTime; + +/** + * Read-only projection used for list operations (e.g. GET /projects). + * Carries the full set of fields needed by the API layer, including + * audit timestamps and display fields not present in {@link ProjectResponse}. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ProjectSummary { + + private String projectKey; + + private String projectName; + + private String projectFlavor; + + private String location; + + private Status status; + + private OffsetDateTime createdAt; + + private OffsetDateTime updatedAt; +} + 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 index 896e7c6d..fdc91b08 100644 --- 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 @@ -2,6 +2,7 @@ import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectSummary; import java.util.List; @@ -12,7 +13,8 @@ public interface ProjectService { ProjectResponse getProject(String projectKey); void updateProjectStatus(String projectKey, String status); - + List findProjectsByName(String projectName); -} + List getProjects(); +} \ No newline at end of file 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 index fe9b34e3..427a9676 100644 --- 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 @@ -6,6 +6,7 @@ import org.opendevstack.apiservice.serviceproject.mapper.ProjectResponseMapper; import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectSummary; import org.opendevstack.apiservice.serviceproject.service.ProjectService; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -79,6 +80,12 @@ public List findProjectsByName(String projectName) { return projectResponseMapper.toCreateProjectResponse(projects); } + @Override + public List getProjects() { + List projects = projectRepository.findAll(); + return projectResponseMapper.toProjectSummary(projects); + } + private String getLdapGroup(String role, String projectKey) { return ldapGroupPattern .replace("{{projectKey}}", projectKey) 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 index ca30c712..c8c9fbfb 100644 --- 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 @@ -9,6 +9,7 @@ import org.opendevstack.apiservice.persistence.repository.ProjectRepository; import org.opendevstack.apiservice.serviceproject.mapper.ProjectResponseMapper; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectSummary; import org.opendevstack.apiservice.serviceproject.model.Status; import java.util.List; @@ -232,4 +233,69 @@ void update_project_status_propagates_repository_exception() { verify(projectRepository).findByProjectKeyIgnoreCase(projectKey); } + + @Test + void get_projects_returns_summaries_when_projects_exist() { + ProjectEntity entity1 = ProjectEntity.builder() + .id(UUID.randomUUID()) + .projectKey("PROJ01") + .projectName("Project One") + .status("Running") + .build(); + + ProjectEntity entity2 = ProjectEntity.builder() + .id(UUID.randomUUID()) + .projectKey("PROJ02") + .projectName("Project Two") + .status("Pending") + .build(); + + ProjectSummary summary1 = ProjectSummary.builder() + .projectKey("PROJ01") + .projectName("Project One") + .status(Status.RUNNING) + .build(); + + ProjectSummary summary2 = ProjectSummary.builder() + .projectKey("PROJ02") + .projectName("Project Two") + .status(Status.PENDING) + .build(); + + when(projectRepository.findAll()).thenReturn(List.of(entity1, entity2)); + when(projectResponseMapper.toProjectSummary(List.of(entity1, entity2))).thenReturn(List.of(summary1, summary2)); + + List result = projectService.getProjects(); + + assertNotNull(result); + assertEquals(2, result.size()); + assertEquals("PROJ01", result.get(0).getProjectKey()); + assertEquals(Status.RUNNING, result.get(0).getStatus()); + assertEquals("PROJ02", result.get(1).getProjectKey()); + assertEquals(Status.PENDING, result.get(1).getStatus()); + verify(projectRepository).findAll(); + verify(projectResponseMapper).toProjectSummary(List.of(entity1, entity2)); + } + + @Test + void get_projects_returns_empty_list_when_no_projects_exist() { + when(projectRepository.findAll()).thenReturn(List.of()); + when(projectResponseMapper.toProjectSummary(List.of())).thenReturn(List.of()); + + List result = projectService.getProjects(); + + assertNotNull(result); + assertEquals(0, result.size()); + verify(projectRepository).findAll(); + verify(projectResponseMapper).toProjectSummary(List.of()); + } + + @Test + void get_projects_propagates_repository_exception() { + when(projectRepository.findAll()).thenThrow(new RuntimeException("DB connection error")); + + assertThrows(RuntimeException.class, () -> projectService.getProjects()); + + verify(projectRepository).findAll(); + } }