From 483282705f2608b76b08c463533b75bac2d2ec16 Mon Sep 17 00:00:00 2001 From: Joselee2908 Date: Wed, 1 Jul 2026 10:55:41 +0200 Subject: [PATCH 1/6] Add API Projects V1 initial version --- .gitignore | 2 + api-project-v1/openapi/api-project-v1.yaml | 102 ++++++++++ api-project-v1/pom.xml | 182 ++++++++++++++++++ .../project/controller/ProjectController.java | 37 ++++ .../project/facade/ProjectsFacade.java | 9 + .../facade/impl/ProjectsFacadeImpl.java | 14 ++ core/pom.xml | 6 + pom.xml | 1 + 8 files changed, 353 insertions(+) create mode 100644 api-project-v1/openapi/api-project-v1.yaml create mode 100644 api-project-v1/pom.xml create mode 100644 api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java create mode 100644 api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java create mode 100644 api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java diff --git a/.gitignore b/.gitignore index 46a7edea..95a8123f 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/project/api +api-project-v1/src/main/java/org/opendevstack/apiservice/project/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..ea3346ad --- /dev/null +++ b/api-project-v1/openapi/api-project-v1.yaml @@ -0,0 +1,102 @@ +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/GetProjectsResponseList' + '404': + description: Project not found. + content: + application/json: + schema: + $ref: '#/components/schemas/GetProjectsResponseList' + "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/GetProjectsResponseList' +components: + schemas: + RestErrorMessage: + properties: + message: + type: string + required: + - message + GetProjectsResponse: + type: object + properties: + projectKey: + type: string + projectName: + type: string + projectFlavor: + type: string + location: + type: string + status: + type: string + createdAt: + type: string + updatedAt: + type: string + GetProjectsResponseList: + type: array + items: + $ref: '#/components/schemas/GetProjectsResponse' \ 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..0b28752d --- /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.project.client.api + org.opendevstack.apiservice.project.client.model + org.opendevstack.apiservice.project.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/project/controller/ProjectController.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java new file mode 100644 index 00000000..fe13a34d --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java @@ -0,0 +1,37 @@ +package org.opendevstack.apiservice.project.controller; + +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.project.client.api.ProjectsApi; +import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; +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; + +import java.util.List; + +@RestController +@RequestMapping(ProjectController.API_BASE_PATH) +@AllArgsConstructor +@Slf4j +public class ProjectController implements ProjectsApi { + + public static final String API_BASE_PATH = "/api/pub/v1/projects"; + + private static final String HTTP_HEADER_LOCATION = "Location"; + + private final ProjectsFacade projectsFacade; + + // private final ProjectRequestValidator projectRequestValidator; + + @GetMapping + @Override + public ResponseEntity> getProjects(@Valid @RequestParam Integer page, + @Valid @RequestParam Integer size) { + return null; + } +} diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java new file mode 100644 index 00000000..157582f0 --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java @@ -0,0 +1,9 @@ +package org.opendevstack.apiservice.project.facade; + +import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; +import java.util.List; + +public interface ProjectsFacade { + + List getProjects(Integer page, Integer size); +} diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java new file mode 100644 index 00000000..0a7a94d5 --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java @@ -0,0 +1,14 @@ +package org.opendevstack.apiservice.project.facade.impl; + +import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; + +import java.util.List; + +public class ProjectsFacadeImpl implements ProjectsFacade { + + @Override + public List getProjects(Integer page, Integer size) { + return List.of(); + } +} 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 From 1874dc731ea9ea2df5b640de01ea025b05c093b5 Mon Sep 17 00:00:00 2001 From: Joselee2908 Date: Wed, 1 Jul 2026 14:44:47 +0200 Subject: [PATCH 2/6] Implement GetProjects API endpoint and add error handling --- api-project-v1/openapi/api-project-v1.yaml | 40 +++++++++--- .../project/controller/ProjectController.java | 15 +++-- .../advice/ProjectExceptionHandler.java | 64 +++++++++++++++++++ .../project/exception/ErrorKey.java | 25 ++++++++ .../project/exception/ErrorMessage.java | 14 ++++ .../project/facade/ProjectsFacade.java | 3 +- .../facade/impl/ProjectsFacadeImpl.java | 18 ++++-- .../project/mapper/ProjectMapper.java | 62 ++++++++++++++++++ .../mapper/ProjectResponseMapper.java | 8 +++ .../serviceproject/model/ProjectSummary.java | 35 ++++++++++ .../service/ProjectService.java | 6 +- .../service/impl/ProjectServiceImpl.java | 7 ++ 12 files changed, 275 insertions(+), 22 deletions(-) create mode 100644 api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java create mode 100644 api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java create mode 100644 api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java create mode 100644 api-project-v1/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectSummary.java diff --git a/api-project-v1/openapi/api-project-v1.yaml b/api-project-v1/openapi/api-project-v1.yaml index ea3346ad..9a472e44 100644 --- a/api-project-v1/openapi/api-project-v1.yaml +++ b/api-project-v1/openapi/api-project-v1.yaml @@ -46,13 +46,13 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/GetProjectsResponseList' + $ref: '#/components/schemas/GetProjectsResponse' '404': description: Project not found. content: application/json: schema: - $ref: '#/components/schemas/GetProjectsResponseList' + $ref: '#/components/schemas/GetProjectsResponse' "401": description: Invalid client token on the request. content: @@ -70,7 +70,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/GetProjectsResponseList' + $ref: '#/components/schemas/GetProjectsResponse' components: schemas: RestErrorMessage: @@ -79,7 +79,7 @@ components: type: string required: - message - GetProjectsResponse: + ProjectsResponse: type: object properties: projectKey: @@ -96,7 +96,31 @@ components: type: string updatedAt: type: string - GetProjectsResponseList: - type: array - items: - $ref: '#/components/schemas/GetProjectsResponse' \ No newline at end of file + 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/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java index fe13a34d..6423eac9 100644 --- a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java @@ -6,6 +6,7 @@ import org.opendevstack.apiservice.project.client.api.ProjectsApi; import org.opendevstack.apiservice.project.facade.ProjectsFacade; import org.opendevstack.apiservice.project.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; @@ -22,16 +23,18 @@ public class ProjectController implements ProjectsApi { public static final String API_BASE_PATH = "/api/pub/v1/projects"; - private static final String HTTP_HEADER_LOCATION = "Location"; - private final ProjectsFacade projectsFacade; - // private final ProjectRequestValidator projectRequestValidator; - @GetMapping @Override - public ResponseEntity> getProjects(@Valid @RequestParam Integer page, + public ResponseEntity getProjects(@Valid @RequestParam Integer page, @Valid @RequestParam Integer size) { - return null; + + 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/project/controller/advice/ProjectExceptionHandler.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java new file mode 100644 index 00000000..5a038490 --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java @@ -0,0 +1,64 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.project.controller.ProjectController; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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; + +@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(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException ex) { + + log.warn("Request body validation error: {}", ex.getMessage()); + + FieldError fieldError = ex.getBindingResult().getFieldErrors().stream() + .findFirst() + .orElse(null); + + GetProjectsResponse response = new GetProjectsResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(HttpStatus.BAD_REQUEST.getReasonPhrase()); + + if (fieldError != null) { + String field = fieldError.getField(); + + ErrorKey key = FIELD_ERROR_MAP.getOrDefault(field, ErrorKey.BAD_REQUEST_BODY); + + 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); + } +} \ No newline at end of file diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java new file mode 100644 index 00000000..a3a78e73 --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java @@ -0,0 +1,25 @@ +package org.opendevstack.apiservice.project.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/project/exception/ErrorMessage.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java new file mode 100644 index 00000000..5ce3fe0e --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java @@ -0,0 +1,14 @@ +package org.opendevstack.apiservice.project.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/project/facade/ProjectsFacade.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java index 157582f0..b66bcaeb 100644 --- a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java @@ -1,9 +1,8 @@ package org.opendevstack.apiservice.project.facade; import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; -import java.util.List; public interface ProjectsFacade { - List getProjects(Integer page, Integer size); + GetProjectsResponse getProjects(Integer page, Integer size); } diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java index 0a7a94d5..43ae84e0 100644 --- a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java @@ -1,14 +1,24 @@ package org.opendevstack.apiservice.project.facade.impl; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.opendevstack.apiservice.project.facade.ProjectsFacade; import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.project.mapper.ProjectMapper; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.stereotype.Component; -import java.util.List; - +@Component("apiProjectFacadeImpl") +@AllArgsConstructor +@Slf4j public class ProjectsFacadeImpl implements ProjectsFacade { + private final ProjectService projectService; + + private final ProjectMapper projectMapper; + @Override - public List getProjects(Integer page, Integer size) { - return List.of(); + 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/project/mapper/ProjectMapper.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java new file mode 100644 index 00000000..b78f66ca --- /dev/null +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java @@ -0,0 +1,62 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.project.client.model.GetProjectsResponseMetadata; +import org.opendevstack.apiservice.project.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 +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/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) From 169f317d1d348906792f5075cb261d6660244bfb Mon Sep 17 00:00:00 2001 From: Joselee2908 Date: Wed, 1 Jul 2026 16:03:17 +0200 Subject: [PATCH 3/6] Add unit tests for ProjectController, ProjectExceptionHandler, ProjectMapper, and ProjectsFacade --- .../controller/ProjectControllerTest.java | 109 +++++++++++ .../advice/ProjectExceptionHandlerTest.java | 92 ++++++++++ .../facade/impl/ProjectsFacadeImplTest.java | 138 ++++++++++++++ .../project/mapper/ProjectMapperTest.java | 173 ++++++++++++++++++ .../service/impl/ProjectServiceImplTest.java | 66 +++++++ 5 files changed, 578 insertions(+) create mode 100644 api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java create mode 100644 api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java create mode 100644 api-project-v1/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java create mode 100644 api-project-v1/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java new file mode 100644 index 00000000..50c21f70 --- /dev/null +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java @@ -0,0 +1,109 @@ +package org.opendevstack.apiservice.project.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.project.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.project.client.model.GetProjectsResponseMetadata; +import org.opendevstack.apiservice.project.client.model.ProjectsResponse; +import org.opendevstack.apiservice.project.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/project/controller/advice/ProjectExceptionHandlerTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java new file mode 100644 index 00000000..e47542d4 --- /dev/null +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java @@ -0,0 +1,92 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.project.controller.ProjectController; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ErrorMessage; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; + +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_method_argument_not_valid_returns_bad_request_for_invalid_page() { + MethodArgumentNotValidException ex = createValidationException("page", "must be >= 0", 0); + + ResponseEntity result = sut.handleMethodArgumentNotValidException(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(ErrorMessage.INVALID_PAGE); + assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); + } + + @Test + void handle_method_argument_not_valid_returns_bad_request_for_invalid_size() { + MethodArgumentNotValidException ex = createValidationException("size", "must be >= 20", 0); + + ResponseEntity result = sut.handleMethodArgumentNotValidException(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(ErrorMessage.INVALID_SIZE); + assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); + } + + @Test + void handle_method_argument_not_valid_returns_bad_request_for_unknown_field() { + MethodArgumentNotValidException ex = createValidationException("unknownField", "invalid value", "bad"); + + ResponseEntity result = sut.handleMethodArgumentNotValidException(ex); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getErrorKey()).isEqualTo(ErrorKey.BAD_REQUEST_BODY.getKey()); + assertThat(result.getBody().getMessage()).isEqualTo(ErrorMessage.BAD_REQUEST); + 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 MethodArgumentNotValidException createValidationException(String field, String defaultMessage, Object rejectedValue) { + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(new Object(), "target"); + bindingResult.addError(new FieldError("target", field, rejectedValue, false, null, null, defaultMessage)); + MethodArgumentNotValidException ex = mock(MethodArgumentNotValidException.class); + when(ex.getBindingResult()).thenReturn(bindingResult); + return ex; + } +} diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java new file mode 100644 index 00000000..fe7dd9b2 --- /dev/null +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java @@ -0,0 +1,138 @@ +package org.opendevstack.apiservice.project.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.project.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.project.client.model.GetProjectsResponseMetadata; +import org.opendevstack.apiservice.project.client.model.ProjectsResponse; +import org.opendevstack.apiservice.project.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/project/mapper/ProjectMapperTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java new file mode 100644 index 00000000..c47eb8cb --- /dev/null +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java @@ -0,0 +1,173 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.project.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/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(); + } } From 9a4134e5e5e7b51292e292de6a589bf6a92dc853 Mon Sep 17 00:00:00 2001 From: "zxBCN Martin_Castro,Jose (IT EDP) EXTERNAL" Date: Wed, 8 Jul 2026 10:45:42 +0200 Subject: [PATCH 4/6] Refactor project package structure to version 1, updating imports and component names --- api-project-v1/pom.xml | 6 +++--- .../apiservice/project/facade/ProjectsFacade.java | 8 -------- .../controller/ProjectController.java | 12 +++++------- .../controller/advice/ProjectExceptionHandler.java | 10 ++++++---- .../{project => projectv1}/exception/ErrorKey.java | 2 +- .../exception/ErrorMessage.java | 2 +- .../apiservice/projectv1/facade/ProjectsFacade.java | 8 ++++++++ .../facade/impl/ProjectsFacadeImpl.java | 10 +++++----- .../{project => projectv1}/mapper/ProjectMapper.java | 10 +++++----- .../controller/ProjectControllerTest.java | 10 +++++----- .../advice/ProjectExceptionHandlerTest.java | 10 +++++----- .../facade/impl/ProjectsFacadeImplTest.java | 10 +++++----- .../mapper/ProjectMapperTest.java | 6 +++--- 13 files changed, 52 insertions(+), 52 deletions(-) delete mode 100644 api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java rename api-project-v1/src/main/java/org/opendevstack/apiservice/{project => projectv1}/controller/ProjectController.java (78%) rename api-project-v1/src/main/java/org/opendevstack/apiservice/{project => projectv1}/controller/advice/ProjectExceptionHandler.java (86%) rename api-project-v1/src/main/java/org/opendevstack/apiservice/{project => projectv1}/exception/ErrorKey.java (90%) rename api-project-v1/src/main/java/org/opendevstack/apiservice/{project => projectv1}/exception/ErrorMessage.java (88%) create mode 100644 api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/ProjectsFacade.java rename api-project-v1/src/main/java/org/opendevstack/apiservice/{project => projectv1}/facade/impl/ProjectsFacadeImpl.java (64%) rename api-project-v1/src/main/java/org/opendevstack/apiservice/{project => projectv1}/mapper/ProjectMapper.java (87%) rename api-project-v1/src/test/java/org/opendevstack/apiservice/{project => projectv1}/controller/ProjectControllerTest.java (91%) rename api-project-v1/src/test/java/org/opendevstack/apiservice/{project => projectv1}/controller/advice/ProjectExceptionHandlerTest.java (92%) rename api-project-v1/src/test/java/org/opendevstack/apiservice/{project => projectv1}/facade/impl/ProjectsFacadeImplTest.java (93%) rename api-project-v1/src/test/java/org/opendevstack/apiservice/{project => projectv1}/mapper/ProjectMapperTest.java (96%) diff --git a/api-project-v1/pom.xml b/api-project-v1/pom.xml index 0b28752d..e3a18fba 100644 --- a/api-project-v1/pom.xml +++ b/api-project-v1/pom.xml @@ -149,9 +149,9 @@ ${project.basedir}/target/generated-sources/openapi spring-boot ${project.basedir}/openapi/api-project-v1.yaml - org.opendevstack.apiservice.project.client.api - org.opendevstack.apiservice.project.client.model - org.opendevstack.apiservice.project.client + org.opendevstack.apiservice.projectv1.client.api + org.opendevstack.apiservice.projectv1.client.model + org.opendevstack.apiservice.projectv1.client false false false diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java deleted file mode 100644 index b66bcaeb..00000000 --- a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.opendevstack.apiservice.project.facade; - -import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; - -public interface ProjectsFacade { - - GetProjectsResponse getProjects(Integer page, Integer size); -} diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/ProjectController.java similarity index 78% rename from api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java rename to api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/ProjectController.java index 6423eac9..e7588f7e 100644 --- a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/ProjectController.java @@ -1,11 +1,11 @@ -package org.opendevstack.apiservice.project.controller; +package org.opendevstack.apiservice.projectv1.controller; import jakarta.validation.Valid; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.opendevstack.apiservice.project.client.api.ProjectsApi; -import org.opendevstack.apiservice.project.facade.ProjectsFacade; -import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; +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; @@ -13,9 +13,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.List; - -@RestController +@RestController("projectControllerV1") @RequestMapping(ProjectController.API_BASE_PATH) @AllArgsConstructor @Slf4j diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandler.java similarity index 86% rename from api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java rename to api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandler.java index 5a038490..0305cfd7 100644 --- a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandler.java @@ -1,11 +1,12 @@ -package org.opendevstack.apiservice.project.controller.advice; +package org.opendevstack.apiservice.projectv1.controller.advice; import lombok.extern.slf4j.Slf4j; -import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; -import org.opendevstack.apiservice.project.controller.ProjectController; -import org.opendevstack.apiservice.project.exception.ErrorKey; +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; @@ -13,6 +14,7 @@ import java.util.Map; +@Component("projectExceptionHandlerV1") @RestControllerAdvice(assignableTypes = ProjectController.class) @Slf4j public class ProjectExceptionHandler { diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorKey.java similarity index 90% rename from api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java rename to api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorKey.java index a3a78e73..c0691b1e 100644 --- a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorKey.java @@ -1,4 +1,4 @@ -package org.opendevstack.apiservice.project.exception; +package org.opendevstack.apiservice.projectv1.exception; public enum ErrorKey { diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorMessage.java similarity index 88% rename from api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java rename to api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorMessage.java index 5ce3fe0e..ce982c47 100644 --- a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/exception/ErrorMessage.java @@ -1,4 +1,4 @@ -package org.opendevstack.apiservice.project.exception; +package org.opendevstack.apiservice.projectv1.exception; public class ErrorMessage { 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/project/facade/impl/ProjectsFacadeImpl.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImpl.java similarity index 64% rename from api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java rename to api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImpl.java index 43ae84e0..e4ebd6b1 100644 --- a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImpl.java @@ -1,14 +1,14 @@ -package org.opendevstack.apiservice.project.facade.impl; +package org.opendevstack.apiservice.projectv1.facade.impl; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.opendevstack.apiservice.project.facade.ProjectsFacade; -import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; -import org.opendevstack.apiservice.project.mapper.ProjectMapper; +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("apiProjectFacadeImpl") +@Component("apiProjectFacadeImplV1") @AllArgsConstructor @Slf4j public class ProjectsFacadeImpl implements ProjectsFacade { diff --git a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapper.java similarity index 87% rename from api-project-v1/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java rename to api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapper.java index b78f66ca..9ce4cd88 100644 --- a/api-project-v1/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java +++ b/api-project-v1/src/main/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapper.java @@ -1,8 +1,8 @@ -package org.opendevstack.apiservice.project.mapper; +package org.opendevstack.apiservice.projectv1.mapper; -import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; -import org.opendevstack.apiservice.project.client.model.GetProjectsResponseMetadata; -import org.opendevstack.apiservice.project.client.model.ProjectsResponse; +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; @@ -10,7 +10,7 @@ import java.time.format.DateTimeFormatter; import java.util.List; -@Component +@Component("projectMapperV1") public class ProjectMapper { private static final int DEFAULT_PAGE = 0; diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/ProjectControllerTest.java similarity index 91% rename from api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java rename to api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/ProjectControllerTest.java index 50c21f70..ecd3c2e1 100644 --- a/api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/ProjectControllerTest.java @@ -1,14 +1,14 @@ -package org.opendevstack.apiservice.project.controller; +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.project.client.model.GetProjectsResponse; -import org.opendevstack.apiservice.project.client.model.GetProjectsResponseMetadata; -import org.opendevstack.apiservice.project.client.model.ProjectsResponse; -import org.opendevstack.apiservice.project.facade.ProjectsFacade; +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; diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandlerTest.java similarity index 92% rename from api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java rename to api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandlerTest.java index e47542d4..aa07d914 100644 --- a/api-project-v1/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/controller/advice/ProjectExceptionHandlerTest.java @@ -1,11 +1,11 @@ -package org.opendevstack.apiservice.project.controller.advice; +package org.opendevstack.apiservice.projectv1.controller.advice; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; -import org.opendevstack.apiservice.project.controller.ProjectController; -import org.opendevstack.apiservice.project.exception.ErrorKey; -import org.opendevstack.apiservice.project.exception.ErrorMessage; +import org.opendevstack.apiservice.projectv1.client.model.GetProjectsResponse; +import org.opendevstack.apiservice.projectv1.controller.ProjectController; +import org.opendevstack.apiservice.projectv1.exception.ErrorKey; +import org.opendevstack.apiservice.projectv1.exception.ErrorMessage; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.BeanPropertyBindingResult; diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImplTest.java similarity index 93% rename from api-project-v1/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java rename to api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImplTest.java index fe7dd9b2..eb69dc4e 100644 --- a/api-project-v1/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/facade/impl/ProjectsFacadeImplTest.java @@ -1,13 +1,13 @@ -package org.opendevstack.apiservice.project.facade.impl; +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.project.client.model.GetProjectsResponse; -import org.opendevstack.apiservice.project.client.model.GetProjectsResponseMetadata; -import org.opendevstack.apiservice.project.client.model.ProjectsResponse; -import org.opendevstack.apiservice.project.mapper.ProjectMapper; +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; diff --git a/api-project-v1/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapperTest.java similarity index 96% rename from api-project-v1/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java rename to api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapperTest.java index c47eb8cb..00c64aeb 100644 --- a/api-project-v1/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java +++ b/api-project-v1/src/test/java/org/opendevstack/apiservice/projectv1/mapper/ProjectMapperTest.java @@ -1,9 +1,9 @@ -package org.opendevstack.apiservice.project.mapper; +package org.opendevstack.apiservice.projectv1.mapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.opendevstack.apiservice.project.client.model.GetProjectsResponse; -import org.opendevstack.apiservice.project.client.model.ProjectsResponse; +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; From 3b617a21089589deab60b8cdb0fe064ece68b461 Mon Sep 17 00:00:00 2001 From: Joselee2908 Date: Wed, 8 Jul 2026 13:39:34 +0200 Subject: [PATCH 5/6] Refactor ProjectController and ProjectExceptionHandler to handle ConstraintViolationException --- .../controller/ProjectController.java | 3 +- .../advice/ProjectExceptionHandler.java | 38 +++++++++----- .../advice/ProjectExceptionHandlerTest.java | 52 +++++++++++-------- 3 files changed, 56 insertions(+), 37 deletions(-) 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 index e7588f7e..68387325 100644 --- 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 @@ -25,8 +25,7 @@ public class ProjectController implements ProjectsApi { @GetMapping @Override - public ResponseEntity getProjects(@Valid @RequestParam Integer page, - @Valid @RequestParam Integer size) { + public ResponseEntity getProjects(Integer page, Integer size) { GetProjectsResponse response = projectsFacade.getProjects(page, size); response.setLocation(API_BASE_PATH); 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 index 0305cfd7..ae75cc81 100644 --- 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 @@ -1,5 +1,8 @@ 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; @@ -13,6 +16,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import java.util.Map; +import java.util.Optional; @Component("projectExceptionHandlerV1") @RestControllerAdvice(assignableTypes = ProjectController.class) @@ -24,25 +28,22 @@ public class ProjectExceptionHandler { "size", ErrorKey.INVALID_SIZE ); - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleMethodArgumentNotValidException( - MethodArgumentNotValidException ex) { + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException( + ConstraintViolationException ex) { + log.warn("Request validation error: {}", ex.getMessage()); - log.warn("Request body validation error: {}", ex.getMessage()); - - FieldError fieldError = ex.getBindingResult().getFieldErrors().stream() - .findFirst() - .orElse(null); + 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 (fieldError != null) { - String field = fieldError.getField(); - - ErrorKey key = FIELD_ERROR_MAP.getOrDefault(field, ErrorKey.BAD_REQUEST_BODY); - + if (error.isPresent()) { + ErrorKey key = error.get(); response.setErrorKey(key.getKey()); response.setMessage(key.getMessage()); } else { @@ -63,4 +64,15 @@ public ResponseEntity handleGenericException(Exception ex) 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/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 index aa07d914..caa5099e 100644 --- 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 @@ -1,16 +1,18 @@ 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.opendevstack.apiservice.projectv1.exception.ErrorMessage; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.validation.BeanPropertyBindingResult; -import org.springframework.validation.FieldError; -import org.springframework.web.bind.MethodArgumentNotValidException; + +import java.util.List; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -26,43 +28,44 @@ void setUp() { } @Test - void handle_method_argument_not_valid_returns_bad_request_for_invalid_page() { - MethodArgumentNotValidException ex = createValidationException("page", "must be >= 0", 0); + void handle_constraint_violation_returns_bad_request_for_invalid_page() { + ConstraintViolationException ex = createConstraintViolationException("page"); - ResponseEntity result = sut.handleMethodArgumentNotValidException(ex); + 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(ErrorMessage.INVALID_PAGE); + assertThat(result.getBody().getMessage()).isEqualTo(ErrorKey.INVALID_PAGE.getMessage()); assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); } @Test - void handle_method_argument_not_valid_returns_bad_request_for_invalid_size() { - MethodArgumentNotValidException ex = createValidationException("size", "must be >= 20", 0); + void handle_constraint_violation_returns_bad_request_for_invalid_size() { + ConstraintViolationException ex = createConstraintViolationException("size"); - ResponseEntity result = sut.handleMethodArgumentNotValidException(ex); + 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(ErrorMessage.INVALID_SIZE); + assertThat(result.getBody().getMessage()).isEqualTo(ErrorKey.INVALID_SIZE.getMessage()); assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); } @Test - void handle_method_argument_not_valid_returns_bad_request_for_unknown_field() { - MethodArgumentNotValidException ex = createValidationException("unknownField", "invalid value", "bad"); + void handle_constraint_violation_returns_bad_request_for_unknown_field() { + ConstraintViolationException ex = createConstraintViolationException("unknownField"); - ResponseEntity result = sut.handleMethodArgumentNotValidException(ex); + 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(ErrorMessage.BAD_REQUEST); + assertThat(result.getBody().getMessage()).isEqualTo(ErrorKey.BAD_REQUEST_BODY.getMessage()); assertThat(result.getBody().getLocation()).isEqualTo(ProjectController.API_BASE_PATH); } @@ -82,11 +85,16 @@ void handle_generic_exception_returns_internal_server_error() { // ------- helpers ------- - private MethodArgumentNotValidException createValidationException(String field, String defaultMessage, Object rejectedValue) { - BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(new Object(), "target"); - bindingResult.addError(new FieldError("target", field, rejectedValue, false, null, null, defaultMessage)); - MethodArgumentNotValidException ex = mock(MethodArgumentNotValidException.class); - when(ex.getBindingResult()).thenReturn(bindingResult); - return ex; + 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)); } } From 622af69a910a7c841a5611c3eb24ae917360c0e5 Mon Sep 17 00:00:00 2001 From: Joselee2908 Date: Thu, 9 Jul 2026 10:16:14 +0200 Subject: [PATCH 6/6] Update .gitignore to reflect new package structure for API Projects V1 --- .gitignore | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 95a8123f..b254917f 100644 --- a/.gitignore +++ b/.gitignore @@ -95,8 +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/project/api -api-project-v1/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