diff --git a/api-project-component-v0/openapi/api-project-component-v0.yaml b/api-project-component-v0/openapi/api-project-component-v0.yaml index 868e72e..c85d72a 100644 --- a/api-project-component-v0/openapi/api-project-component-v0.yaml +++ b/api-project-component-v0/openapi/api-project-component-v0.yaml @@ -27,7 +27,7 @@ paths: - name: projectId in: path required: true - description: Project id + description: Project key schema: type: string requestBody: @@ -37,8 +37,8 @@ paths: schema: $ref: '#/components/schemas/CreateComponentRequest' responses: - '201': - description: Created + '200': + description: OK headers: Location: schema: @@ -49,14 +49,26 @@ paths: $ref: '#/components/schemas/CreateComponentResponse' '400': description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/CreateComponentResponse' '401': $ref: '#/components/responses/UnauthorizedError' '403': description: Forbidden '404': description: Endpoint not found / Project not found / Product not found + content: + application/json: + schema: + $ref: '#/components/schemas/CreateComponentResponse' '500': description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/CreateComponentResponse' /projects/{projectId}/components/{componentId}: get: @@ -69,7 +81,7 @@ paths: - name: projectId in: path required: true - description: Project id + description: Project key schema: type: string - name: componentId @@ -78,6 +90,7 @@ paths: description: Component id schema: type: string + format: uuid responses: '200': description: Component information @@ -105,6 +118,10 @@ components: schemas: Component: type: object + x-class-extra-annotation: | + @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) + required: + - params properties: id: type: string @@ -117,50 +134,94 @@ components: description: Description of the product productName: type: string - description: Name of the product (e.g. Docker plain) + description: Name of the product productId: type: string description: Product ID environment: - type: string - description: Environment (e.g. DEV) + $ref: '#/components/schemas/EnvironmentsDTO' + description: Environment status: - type: string - description: Status of the component (e.g. READY, NOT_READY) + $ref: '#/components/schemas/ComponentsStatusDTO' + description: Status of the component resultTraceback: - type: string + type: object + nullable: true description: Traceback information in case of error + x-field-extra-annotation: | + @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_EMPTY) repositoryURL: type: string description: URL of the repository (for ODS products) params: type: object description: Additional parameters (key-value pairs) + additionalProperties: true component-type: type: string description: Type of component (ods|awx) CreateComponentResponse: type: object + required: + - timestamp + - httpStatus + - errorKey + - message + - path properties: - errorCode: + timestamp: type: integer - field: + format: int64 + httpStatus: + type: string + errorKey: + type: string + error: type: string message: type: string - location: + path: type: string - format: url CreateComponentRequest: type: object + required: + - name + - productId + - params properties: name: type: string - description: component name + description: Component name + minLength: 2 + maxLength: 52 + pattern: "^[a-z]+(-?[a-z0-9]+)*$" + x-field-extra-annotation: | + @Size(min=2, message = "The component name must contain more than 1 character.") + @Size(max=52, message = "The component name must contain less than 53 characters.") + @Pattern(regexp = "^[a-z]+(-?[a-z0-9]+)*$", message = "Only lowercase letters and numbers are allowed (example: componentname8), with optional dashes in between (example: my-component). The first character must be a letter.") productId: type: string - description: product id + description: Product id + registerOnly: + type: boolean + description: Register only flag (no provisioning) + default: false params: type: object - additionalProperties: - type: string \ No newline at end of file + description: Additional parameters (key-value pairs) + additionalProperties: true + EnvironmentsDTO: + type: string + enum: + - DEV + - QA + - PROD + ComponentsStatusDTO: + type: string + enum: + - READY + - RUNNING + - FAILED + - DELETING + - DELETED + - UNKNOWN diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/config/JsonNullableConfig.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/config/JsonNullableConfig.java new file mode 100644 index 0000000..5230803 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/config/JsonNullableConfig.java @@ -0,0 +1,26 @@ +package org.opendevstack.apiservice.project.config; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.openapitools.jackson.nullable.JsonNullable; +import org.openapitools.jackson.nullable.JsonNullableModule; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JsonNullableConfig { + + @Bean + public Jackson2ObjectMapperBuilderCustomizer jsonNullableCustomizer() { + return builder -> { + builder.modulesToInstall(JsonNullableModule.class); + builder.postConfigurer(this::configureJsonNullableInclusion); + }; + } + + private void configureJsonNullableInclusion(ObjectMapper objectMapper) { + objectMapper.configOverride(JsonNullable.class) + .setInclude(JsonInclude.Value.construct(JsonInclude.Include.NON_ABSENT, JsonInclude.Include.NON_ABSENT)); + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java index c3f876a..28fa4a0 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java @@ -1,24 +1,54 @@ package org.opendevstack.apiservice.project.controller; +import org.opendevstack.apiservice.project.exception.ComponentErrorKey; import org.opendevstack.apiservice.project.model.CreateComponentResponse; import org.springframework.http.HttpStatus; -public class ComponentsResponseFactory { +public final class ComponentsResponseFactory { private ComponentsResponseFactory() { } - public static CreateComponentResponse error(String projectId) { + public static CreateComponentResponse entityCreated(String projectId, String componentId) { + String path = String.format("/api/pub/v0/projects/%s/components/%s", projectId, componentId); + return ok(path, "Component created"); + } + + public static CreateComponentResponse badRequest(String path, String message, ComponentErrorKey errorKey) { + return buildResponse(HttpStatus.BAD_REQUEST, errorKey, path, message); + } + + public static CreateComponentResponse forbidden(String path, String message, ComponentErrorKey errorKey) { + return buildResponse(HttpStatus.FORBIDDEN, errorKey, path, message); + } + + public static CreateComponentResponse notFound(String path, String message, ComponentErrorKey errorKey) { + return buildResponse(HttpStatus.NOT_FOUND, errorKey, path, message); + } + + public static CreateComponentResponse internalError(String path, String message, ComponentErrorKey errorKey) { + return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, errorKey, path, message); + } + + private static CreateComponentResponse buildResponse(HttpStatus httpStatus, ComponentErrorKey errorKey, String path, + String message) { CreateComponentResponse response = new CreateComponentResponse(); - response.setErrorCode(HttpStatus.INTERNAL_SERVER_ERROR.value()); - response.setMessage("Failed to create component for project '" + projectId + "'"); + response.setTimestamp(System.currentTimeMillis()); + response.setHttpStatus(httpStatus.name()); + response.setErrorKey(errorKey.getKey()); + response.setError(errorKey.getMessage()); + response.setMessage(message); + response.setPath(path); return response; } - public static CreateComponentResponse entityCreated(String projectId, String componentId) { + public static CreateComponentResponse ok(String path, String message) { CreateComponentResponse response = new CreateComponentResponse(); - response.setErrorCode(HttpStatus.CREATED.value()); - response.setMessage(componentId + " component created successfully in project " + projectId); + response.setTimestamp(System.currentTimeMillis()); + response.setHttpStatus(HttpStatus.OK.name()); + response.setErrorKey(ComponentErrorKey.OK.getKey()); + response.setMessage(message); + response.setPath(path); return response; } } diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java index f8b0ae8..d288cae 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java @@ -3,6 +3,8 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.opendevstack.apiservice.project.api.ProjectComponentsApi; +import org.opendevstack.apiservice.project.exception.ComponentCreationException; +import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; import org.opendevstack.apiservice.project.facade.ComponentsFacade; import org.opendevstack.apiservice.project.mapper.ComponentResponseMapper; import org.opendevstack.apiservice.project.model.Component; @@ -13,44 +15,43 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.UUID; + @RestController @AllArgsConstructor @Slf4j -@RequestMapping("/api/pub/v0") +@RequestMapping(ProjectComponentsController.API_BASE_PATH) public class ProjectComponentsController implements ProjectComponentsApi { + public static final String API_BASE_PATH = "/api/pub/v0"; + private final ComponentsFacade componentsFacade; private final ComponentResponseMapper componentResponseMapper; @Override public ResponseEntity createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { - try { - Component component = componentsFacade.createProjectComponent(projectId, createComponentRequest); - log.info("Created component {} for project id {} and request {}", component, projectId, createComponentRequest); - if (component == null) { - log.error("Failed to create component for project '{}'", projectId); - return componentResponseMapper.toResponseEntity(ComponentsResponseFactory.error(projectId)); - } - return componentResponseMapper.toResponseEntity(ComponentsResponseFactory.entityCreated(projectId, component.getId())); - } catch (Exception e) { - log.error("Error while trying to create component for project '" + projectId + "': " + e.getMessage(), e); - return componentResponseMapper.toResponseEntity(ComponentsResponseFactory.error(projectId)); + Component component = componentsFacade.createProjectComponent(projectId, createComponentRequest); + if (component == null) { + throw new ComponentCreationException(String.format("Failed to create component for project '%s'", projectId)); } + + log.info("Created component {} for project id {} and request {}", component, projectId, createComponentRequest); + return componentResponseMapper.toResponseEntity( + ComponentsResponseFactory.entityCreated(projectId, component.getId()) + ); } @Override - public ResponseEntity getProjectComponent(String projectId, String componentId) { - try { - Component component = componentsFacade.getProjectComponent(projectId, componentId); - log.info("Retrieved component '{}' for project '{}': {}", componentId, projectId, component); - if (component == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); - } - return ResponseEntity.status(HttpStatus.OK).body(component); - } catch (Exception e) { - log.error("Error retrieving component '{}' for project '{}': {}", componentId, projectId, e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + public ResponseEntity getProjectComponent(String projectId, UUID componentId) { + Component component = componentsFacade.getProjectComponent(projectId, componentId.toString()); + if (component == null) { + throw new ComponentNotFoundException( + String.format("Component '%s' not found for project '%s'", componentId, projectId) + ); } + + log.info("Retrieved component '{}' for project '{}': {}", componentId, projectId, component); + return ResponseEntity.status(HttpStatus.OK).body(component); } } diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java new file mode 100644 index 0000000..f561d3d --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java @@ -0,0 +1,156 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.project.controller.ComponentsResponseFactory; +import org.opendevstack.apiservice.project.controller.ProjectComponentsController; +import org.opendevstack.apiservice.project.exception.ComponentCreationException; +import org.opendevstack.apiservice.project.exception.ComponentErrorKey; +import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.model.CreateComponentResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.access.AccessDeniedException; +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 org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.util.Map; + +@RestControllerAdvice(assignableTypes = ProjectComponentsController.class) +@Slf4j +public class ProjectComponentsExceptionHandler { + + private static final Map FIELD_ERROR_MAP = Map.of( + "name", ComponentErrorKey.COMPONENT_PARAM_NOT_MEET_REGEX, + "productId", ComponentErrorKey.INVALID_PARAMETERS, + "params", ComponentErrorKey.INVALID_PARAMETERS + ); + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException ex, + HttpServletRequest request) { + + log.warn("Request body validation error: {}", ex.getMessage()); + + FieldError fieldError = ex.getBindingResult().getFieldErrors().stream() + .findFirst() + .orElse(null); + + ComponentErrorKey errorKey = ComponentErrorKey.INVALID_PARAMETERS; + String message = ComponentErrorKey.INVALID_PARAMETERS.getMessage(); + + if (fieldError != null) { + errorKey = FIELD_ERROR_MAP.getOrDefault(fieldError.getField(), ComponentErrorKey.INVALID_PARAMETERS); + message = String.format("Field: %s %s", fieldError.getField(), fieldError.getDefaultMessage()); + } + + CreateComponentResponse response = ComponentsResponseFactory.badRequest( + request.getRequestURI(), + message, + errorKey + ); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleMethodArgumentTypeMismatchException( + MethodArgumentTypeMismatchException ex, + HttpServletRequest request) { + + log.warn("Request parameter validation error: {}", ex.getMessage()); + + CreateComponentResponse response = ComponentsResponseFactory.badRequest( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.INVALID_PARAMETERS + ); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(ComponentNotFoundException.class) + public ResponseEntity handleComponentNotFoundException( + ComponentNotFoundException ex, + HttpServletRequest request) { + + log.warn("Component not found: {}", ex.getMessage()); + + CreateComponentResponse response = ComponentsResponseFactory.notFound( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.COMPONENT_NOT_FOUND + ); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDeniedException( + AccessDeniedException ex, + HttpServletRequest request) { + + log.warn("Access denied: {}", ex.getMessage()); + + CreateComponentResponse response = ComponentsResponseFactory.forbidden( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.ACCESS_DENIED + ); + + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException ex, + HttpServletRequest request) { + + log.warn("Request body format error: {}", ex.getMessage()); + + CreateComponentResponse response = ComponentsResponseFactory.badRequest( + request.getRequestURI(), + "Params property should be a valid json.", + ComponentErrorKey.COMPONENT_PARAM_INVALID_FORMAT + ); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(ComponentCreationException.class) + public ResponseEntity handleComponentCreationException( + ComponentCreationException ex, + HttpServletRequest request) { + + log.error("Component creation failed: {}", ex.getMessage(), ex); + + CreateComponentResponse response = ComponentsResponseFactory.internalError( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.INTERNAL_ERROR + ); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException( + Exception ex, + HttpServletRequest request) { + + log.error("Unexpected error: {}", ex.getMessage(), ex); + + CreateComponentResponse response = ComponentsResponseFactory.internalError( + request.getRequestURI(), + "An error occurred while processing the request.", + ComponentErrorKey.INTERNAL_ERROR + ); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentCreationException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentCreationException.java new file mode 100644 index 0000000..f035918 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentCreationException.java @@ -0,0 +1,8 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentCreationException extends RuntimeException { + + public ComponentCreationException(String message) { + super(message); + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorKey.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorKey.java new file mode 100644 index 0000000..9af565b --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorKey.java @@ -0,0 +1,30 @@ +package org.opendevstack.apiservice.project.exception; + +public enum ComponentErrorKey { + + OK("000", ComponentErrorMessage.SUCCESS), + ACCESS_DENIED("002", ComponentErrorMessage.FORBIDDEN), + INTERNAL_ERROR("003", ComponentErrorMessage.INTERNAL_ERROR), + INVALID_PARAMETERS("006", ComponentErrorMessage.BAD_REQUEST), + COMPONENT_PARAM_NOT_MEET_REGEX("010", ComponentErrorMessage.BAD_REQUEST), + PROJECT_NOT_FOUND("012", ComponentErrorMessage.NOT_FOUND), + COMPONENT_NOT_FOUND("013", ComponentErrorMessage.NOT_FOUND), + BAD_REQUEST_BODY("014", ComponentErrorMessage.BAD_REQUEST), + COMPONENT_PARAM_INVALID_FORMAT("017", ComponentErrorMessage.BAD_REQUEST); + + private final String key; + private final String message; + + ComponentErrorKey(String key, String message) { + this.key = key; + this.message = message; + } + + public String getKey() { + return key; + } + + public String getMessage() { + return message; + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorMessage.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorMessage.java new file mode 100644 index 0000000..116d439 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorMessage.java @@ -0,0 +1,14 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentErrorMessage { + + public static final String BAD_REQUEST = "Bad Request"; + public static final String NOT_FOUND = "Not Found"; + public static final String FORBIDDEN = "Forbidden"; + public static final String INTERNAL_ERROR = "Internal error"; + public static final String SUCCESS = "Success"; + + private ComponentErrorMessage() { + // prevent instantiation + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentNotFoundException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentNotFoundException.java new file mode 100644 index 0000000..8ee5ffc --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentNotFoundException.java @@ -0,0 +1,8 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentNotFoundException extends RuntimeException { + + public ComponentNotFoundException(String message) { + super(message); + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java index f36476f..42bab0e 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java @@ -5,6 +5,8 @@ import org.opendevstack.apiservice.externalservice.marketplace.model.CreateComponentParameter; import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.opendevstack.apiservice.project.exception.ComponentCreationException; +import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; import org.opendevstack.apiservice.project.mapper.MarketplaceMapper; import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.CreateComponentRequest; @@ -25,7 +27,9 @@ public Component getProjectComponent(String projectId, String componentId) { ProjectComponent marketplaceComponent = marketplaceExternalService.getProjectComponent(projectId, componentId); if (marketplaceComponent == null) { log.info("Marketplace component with id {} not found", componentId); - return null; + throw new ComponentNotFoundException( + String.format("Component '%s' not found for project '%s'", componentId, projectId) + ); } return marketplaceMapper.mapMarketplaceComponentToV0Component(marketplaceComponent); } @@ -35,7 +39,9 @@ public Component createProjectComponent(String projectId, CreateComponentRequest ProjectComponent marketplaceComponent = marketplaceExternalService.createProjectComponent(projectId, createComponentParameterList); if (marketplaceComponent == null) { log.error("Failed to create component in marketplace for project with id {}", projectId); - return null; + throw new ComponentCreationException( + String.format("Failed to create component for project '%s'", projectId) + ); } return marketplaceMapper.mapMarketplaceComponentToV0Component(marketplaceComponent); } diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/MarketplaceExternalServicePlaceholder.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/MarketplaceExternalServicePlaceholder.java deleted file mode 100644 index 990374c..0000000 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/MarketplaceExternalServicePlaceholder.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.opendevstack.apiservice.project.facade; - -import lombok.extern.slf4j.Slf4j; -import org.opendevstack.apiservice.externalservice.api.ExternalService; -import org.opendevstack.apiservice.project.model.Component; -import org.opendevstack.apiservice.project.model.CreateComponentRequest; -import org.springframework.stereotype.Service; - -@Service -@Slf4j -class MarketplaceExternalServicePlaceholder implements ExternalService { - - @Override - public boolean isHealthy() { - return false; - } - - public Component getProjectComponent(String projectId, String componentId) { - log.info("Get component with id '" + componentId + "' for project '" + projectId + "'"); - return null; - } - - public Component createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { - log.info("Creating component for project '" + projectId + "'" + " with request: " + createComponentRequest); - return null; - } -} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/ComponentResponseMapper.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/ComponentResponseMapper.java index 4c3198b..846f299 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/ComponentResponseMapper.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/ComponentResponseMapper.java @@ -9,7 +9,6 @@ public interface ComponentResponseMapper { default ResponseEntity toResponseEntity(CreateComponentResponse response) { - return new ResponseEntity<>(response, HttpStatus.valueOf(response.getErrorCode())); + return new ResponseEntity<>(response, HttpStatus.valueOf(response.getHttpStatus())); } - } diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java index f142064..8e84500 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java @@ -1,30 +1,73 @@ package org.opendevstack.apiservice.project.mapper; - +import org.mapstruct.IterableMapping; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; import org.opendevstack.apiservice.externalservice.marketplace.model.CreateComponentParameter; import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; import org.opendevstack.apiservice.project.model.Component; +import org.opendevstack.apiservice.project.model.ComponentsStatusDTO; import org.opendevstack.apiservice.project.model.CreateComponentRequest; +import org.opendevstack.apiservice.project.model.EnvironmentsDTO; import java.util.List; +import java.util.Map; +import java.util.UUID; @Mapper(componentModel = "spring") public interface MarketplaceMapper { - default Component mapMarketplaceComponentToV0Component(ProjectComponent source) { - if (source == null) { + @Mapping(target = "id", source = "componentId", qualifiedByName = "uuidToString") + @Mapping(target = "environment", source = "environment", qualifiedByName = "toEnvironment") + @Mapping(target = "status", source = "status", qualifiedByName = "toComponentStatus") + @Mapping(target = "params", expression = "java(java.util.Collections.emptyMap())") + @Mapping(target = "resultTraceback", ignore = true) + Component mapMarketplaceComponentToV0Component(ProjectComponent source); + + default List mapCreateComponentRequestToCreateComponentParameterList(CreateComponentRequest createComponentRequest) { + if (createComponentRequest == null || createComponentRequest.getParams() == null) { + return List.of(); + } + + return mapEntriesToCreateComponentParameterList(createComponentRequest.getParams().entrySet().stream().toList()); + } + + @IterableMapping(qualifiedByName = "toCreateComponentParameter") + List mapEntriesToCreateComponentParameterList(List> entries); + + @Named("toCreateComponentParameter") + @Mapping(target = "name", source = "key") + @Mapping(target = "type", constant = "string") + @Mapping(target = "value", expression = "java(String.valueOf(entry.getValue()))") + CreateComponentParameter toCreateComponentParameter(Map.Entry entry); + + @Named("uuidToString") + default String uuidToString(UUID sourceId) { + return sourceId != null ? sourceId.toString() : null; + } + + @Named("toComponentStatus") + default ComponentsStatusDTO toComponentStatus(String sourceStatus) { + if (sourceStatus == null || sourceStatus.isBlank()) { + return null; + } + try { + return ComponentsStatusDTO.fromValue(sourceStatus); + } catch (IllegalArgumentException ex) { return null; } - Component target = new Component(); - target.setId(source.getComponentId()); - target.setStatus(source.getStatus()); - return target; } - default List mapCreateComponentRequestToCreateComponentParameterList(CreateComponentRequest createComponentRequest) { - return createComponentRequest.getParams().entrySet().stream() - .map(entry -> new CreateComponentParameter(entry.getKey(), "string", entry.getValue())) - .toList(); + @Named("toEnvironment") + default EnvironmentsDTO toEnvironment(String sourceEnv) { + if (sourceEnv == null || sourceEnv.isBlank()) { + return null; + } + try { + return EnvironmentsDTO.fromValue(sourceEnv); + } catch (IllegalArgumentException ex) { + return null; + } } } diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java index a98c778..0b67225 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java @@ -1,26 +1,32 @@ 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.junit.jupiter.api.extension.ExtendWith; import org.mapstruct.factory.Mappers; import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.project.exception.ComponentCreationException; +import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.facade.ComponentsFacade; import org.opendevstack.apiservice.project.mapper.ComponentResponseMapper; import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.CreateComponentRequest; import org.opendevstack.apiservice.project.model.CreateComponentResponse; -import org.opendevstack.apiservice.project.facade.ComponentsFacade; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import java.util.UUID; + import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.*; +import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestComponent; +import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestCreateComponentRequest; -@ExtendWith(MockitoExtension.class) class ProjectComponentsControllerTest { @Mock @@ -30,71 +36,93 @@ class ProjectComponentsControllerTest { private ProjectComponentsController projectComponentsController; + private AutoCloseable openMocks; + @BeforeEach void setup() { + openMocks = MockitoAnnotations.openMocks(this); projectComponentsController = new ProjectComponentsController(componentsFacade, componentResponseMapper); } + @AfterEach + void tearDown() throws Exception { + openMocks.close(); + } + @Test - void testCreateProjectComponent_whenSuccess_thenReturnOk() throws Exception { - Component testComponent = buildTestComponent(); - String testProjectId = "testProjectId"; - CreateComponentRequest testCreateComponentRequest = buildTestCreateComponentRequest(); - CreateComponentResponse testServiceResponseSuccess = buildTestCreateComponentResponseSuccess(testComponent.getId(), - testProjectId); + void create_project_component_returns_ok_when_component_is_created() { + String projectId = "testProjectId"; + CreateComponentRequest request = buildTestCreateComponentRequest(); + Component createdComponent = buildTestComponent(); + createdComponent.setId("component-123"); - when(componentsFacade.createProjectComponent(anyString(), any(CreateComponentRequest.class))) - .thenReturn(testComponent); + when(componentsFacade.createProjectComponent(eq(projectId), any(CreateComponentRequest.class))) + .thenReturn(createdComponent); - ResponseEntity response = projectComponentsController.createProjectComponent(testProjectId, - testCreateComponentRequest); + ResponseEntity response = projectComponentsController.createProjectComponent(projectId, request); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody()).isNotNull(); - assertThat(response.getBody()).isEqualTo(testServiceResponseSuccess); + assertThat(response.getBody().getHttpStatus()).isEqualTo(HttpStatus.OK.name()); + assertThat(response.getBody().getErrorKey()).isEqualTo("000"); + assertThat(response.getBody().getMessage()).isEqualTo("Component created"); + assertThat(response.getBody().getPath()).isEqualTo("/api/pub/v0/projects/testProjectId/components/component-123"); + verify(componentsFacade).createProjectComponent(projectId, request); } @Test - void testCreateProjectComponent_whenFailure_thenReturnErrorResponse() throws Exception { - CreateComponentRequest testCreateComponentRequest = buildTestCreateComponentRequest(); - String testProjectId = "testProjectId"; - CreateComponentResponse testServiceResponseFailure = buildTestCreateComponentResponseFailure(testProjectId); + void create_project_component_returns_internal_error_when_component_creation_returns_null() { + String projectId = "testProjectId"; + CreateComponentRequest request = buildTestCreateComponentRequest(); - when(componentsFacade.createProjectComponent(anyString(), any(CreateComponentRequest.class))) + when(componentsFacade.createProjectComponent(eq(projectId), any(CreateComponentRequest.class))) .thenReturn(null); - ResponseEntity response = projectComponentsController.createProjectComponent(testProjectId, - testCreateComponentRequest); + assertThatThrownBy(() -> projectComponentsController.createProjectComponent(projectId, request)) + .isInstanceOf(ComponentCreationException.class) + .hasMessage("Failed to create component for project 'testProjectId'"); + verify(componentsFacade).createProjectComponent(projectId, request); + } - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); - assertThat(response.getBody()).isNotNull(); - assertThat(response.getBody()).isEqualTo(testServiceResponseFailure); + @Test + void create_project_component_propagates_exception_when_facade_throws_exception() { + String projectId = "testProjectId"; + CreateComponentRequest request = buildTestCreateComponentRequest(); + + when(componentsFacade.createProjectComponent(eq(projectId), any(CreateComponentRequest.class))) + .thenThrow(new RuntimeException("boom")); + + assertThatThrownBy(() -> projectComponentsController.createProjectComponent(projectId, request)) + .isInstanceOf(RuntimeException.class) + .hasMessage("boom"); + verify(componentsFacade).createProjectComponent(projectId, request); } @Test - void testGetProjectComponent_whenSuccess_thenReturnOk() throws Exception { + void get_project_component_returns_ok_when_component_exists() { + String projectId = "projectId"; + UUID componentId = UUID.randomUUID(); Component testComponent = buildTestComponent(); - when(componentsFacade.getProjectComponent(anyString(), anyString())) - .thenReturn(testComponent); + when(componentsFacade.getProjectComponent(projectId, componentId.toString())).thenReturn(testComponent); - ResponseEntity response = projectComponentsController.getProjectComponent("projectId", - "testId"); + ResponseEntity response = projectComponentsController.getProjectComponent(projectId, componentId); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isNotNull(); assertThat(response.getBody()).isEqualTo(testComponent); + verify(componentsFacade).getProjectComponent(projectId, componentId.toString()); } @Test - void testGetProjectComponent_whenFailure_thenReturnErrorResponse() throws Exception { - when(componentsFacade.getProjectComponent(anyString(), anyString())) - .thenReturn(null); + void get_project_component_throws_not_found_when_component_does_not_exist() { + String projectId = "projectId"; + UUID componentId = UUID.randomUUID(); - ResponseEntity response = projectComponentsController.getProjectComponent("projectId", - "testId"); + when(componentsFacade.getProjectComponent(projectId, componentId.toString())).thenReturn(null); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); - assertThat(response.getBody()).isNull(); + assertThatThrownBy(() -> projectComponentsController.getProjectComponent(projectId, componentId)) + .isInstanceOf(ComponentNotFoundException.class) + .hasMessage("Component '" + componentId + "' not found for project 'projectId'"); + verify(componentsFacade).getProjectComponent(projectId, componentId.toString()); } } \ No newline at end of file diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java new file mode 100644 index 0000000..e6bced2 --- /dev/null +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java @@ -0,0 +1,167 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import jakarta.servlet.http.HttpServletRequest; +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.exception.ComponentCreationException; +import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.model.CreateComponentResponse; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +class ProjectComponentsExceptionHandlerTest { + + @Mock + private HttpServletRequest request; + + private ProjectComponentsExceptionHandler handler; + + private AutoCloseable openMocks; + + @BeforeEach + void setup() { + openMocks = MockitoAnnotations.openMocks(this); + handler = new ProjectComponentsExceptionHandler(); + when(request.getRequestURI()).thenReturn("/api/pub/v0/projects/test-project/components/"); + } + + @AfterEach + void tearDown() throws Exception { + openMocks.close(); + } + + @Test + void handle_method_argument_not_valid_exception_returns_bad_request_with_regex_error_key() throws Exception { + DummyRequest dummyRequest = new DummyRequest(); + BindingResult bindingResult = new BeanPropertyBindingResult(dummyRequest, "createComponentRequest"); + bindingResult.rejectValue("name", "Pattern", "has invalid format"); + + Method method = DummyController.class.getDeclaredMethod("dummyMethod", DummyRequest.class); + MethodParameter methodParameter = new MethodParameter(method, 0); + MethodArgumentNotValidException exception = new MethodArgumentNotValidException(methodParameter, bindingResult); + + ResponseEntity response = handler.handleMethodArgumentNotValidException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getHttpStatus()).isEqualTo(HttpStatus.BAD_REQUEST.name()); + assertThat(response.getBody().getErrorKey()).isEqualTo("010"); + assertThat(response.getBody().getMessage()).isEqualTo("Field: name has invalid format"); + assertThat(response.getBody().getPath()).isEqualTo("/api/pub/v0/projects/test-project/components/"); + } + + @Test + void handle_method_argument_type_mismatch_exception_returns_bad_request() throws Exception { + Method method = DummyController.class.getDeclaredMethod("dummyMethod", DummyRequest.class); + MethodParameter methodParameter = new MethodParameter(method, 0); + MethodArgumentTypeMismatchException exception = new MethodArgumentTypeMismatchException( + "not-a-uuid", + String.class, + "componentId", + methodParameter, + new IllegalArgumentException("Invalid UUID") + ); + + ResponseEntity response = handler.handleMethodArgumentTypeMismatchException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getErrorKey()).isEqualTo("006"); + assertThat(response.getBody().getPath()).isEqualTo("/api/pub/v0/projects/test-project/components/"); + } + + @Test + void handle_component_not_found_exception_returns_not_found() { + ComponentNotFoundException exception = new ComponentNotFoundException("Component not found"); + + ResponseEntity response = handler.handleComponentNotFoundException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getErrorKey()).isEqualTo("013"); + assertThat(response.getBody().getMessage()).isEqualTo("Component not found"); + } + + @Test + void handle_access_denied_exception_returns_forbidden() { + AccessDeniedException exception = new AccessDeniedException("Forbidden"); + + ResponseEntity response = handler.handleAccessDeniedException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getErrorKey()).isEqualTo("002"); + assertThat(response.getBody().getMessage()).isEqualTo("Forbidden"); + } + + @Test + void handle_http_message_not_readable_exception_returns_bad_request() { + HttpMessageNotReadableException exception = new HttpMessageNotReadableException("Malformed JSON"); + + ResponseEntity response = handler.handleHttpMessageNotReadableException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getErrorKey()).isEqualTo("017"); + assertThat(response.getBody().getMessage()).isEqualTo("Params property should be a valid json."); + } + + @Test + void handle_component_creation_exception_returns_internal_server_error() { + ComponentCreationException exception = new ComponentCreationException("Creation failed"); + + ResponseEntity response = handler.handleComponentCreationException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getErrorKey()).isEqualTo("003"); + assertThat(response.getBody().getMessage()).isEqualTo("Creation failed"); + } + + @Test + void handle_generic_exception_returns_internal_server_error() { + RuntimeException exception = new RuntimeException("boom"); + + ResponseEntity response = handler.handleGenericException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getErrorKey()).isEqualTo("003"); + assertThat(response.getBody().getMessage()).isEqualTo("An error occurred while processing the request."); + } + + private static class DummyRequest { + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + @SuppressWarnings("unused") + private static class DummyController { + public void dummyMethod(@ModelAttribute DummyRequest request) { + // no implementation needed for testing purposes + } + } +} diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java index 3935839..978dae8 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java @@ -1,28 +1,31 @@ package org.opendevstack.apiservice.project.facade; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.mapstruct.factory.Mappers; import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.MockitoAnnotations; import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.opendevstack.apiservice.project.exception.ComponentCreationException; +import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; import org.opendevstack.apiservice.project.mapper.MarketplaceMapper; import org.opendevstack.apiservice.project.model.Component; +import org.opendevstack.apiservice.project.model.ComponentsStatusDTO; import org.opendevstack.apiservice.project.model.CreateComponentRequest; import java.util.List; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestCreateComponentRequest; import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestMarketplaceComponent; -@ExtendWith(MockitoExtension.class) class ComponentsFacadeTest { private final MarketplaceMapper marketplaceMapper = Mappers.getMapper(MarketplaceMapper.class); @@ -32,54 +35,71 @@ class ComponentsFacadeTest { private ComponentsFacade componentsFacade; + private AutoCloseable openMocks; + @BeforeEach void setup() { + openMocks = MockitoAnnotations.openMocks(this); componentsFacade = new ComponentsFacade(marketplaceExternalService, marketplaceMapper); } + @AfterEach + void tearDown() throws Exception { + openMocks.close(); + } + @Test - void testGetProjectComponent_whenSuccess_thenReturnCorrectComponent() throws Exception { - ProjectComponent testComponent = buildTestMarketplaceComponent(); + void get_project_component_returns_mapped_component_when_marketplace_returns_data() { + ProjectComponent marketplaceComponent = buildTestMarketplaceComponent(); - when(marketplaceExternalService.getProjectComponent(anyString(), eq("testId"))) - .thenReturn(testComponent); + when(marketplaceExternalService.getProjectComponent("testProject", "testComponent")) + .thenReturn(marketplaceComponent); - Component retrievedComponent = componentsFacade.getProjectComponent("testId", "testId"); - assertThat(retrievedComponent.getId()).isEqualTo(testComponent.getComponentId()); - assertThat(retrievedComponent.getStatus()).isEqualTo(testComponent.getStatus()); + Component retrievedComponent = componentsFacade.getProjectComponent("testProject", "testComponent"); + + assertThat(retrievedComponent).isNotNull(); + assertThat(retrievedComponent.getId()).isEqualTo(marketplaceComponent.getComponentId().toString()); + assertThat(retrievedComponent.getStatus()).isEqualTo(ComponentsStatusDTO.RUNNING); + verify(marketplaceExternalService).getProjectComponent("testProject", "testComponent"); } @Test - void testGetProjectComponent_whenNoComponentFound_thenReturnNull() throws Exception { - when(marketplaceExternalService.getProjectComponent(anyString(), eq("testId"))) + void get_project_component_throws_not_found_when_marketplace_returns_null() { + when(marketplaceExternalService.getProjectComponent("testProject", "testComponent")) .thenReturn(null); - Component retrievedComponent = componentsFacade.getProjectComponent("testId", "testId"); - assertThat(retrievedComponent).isNull(); + assertThatThrownBy(() -> componentsFacade.getProjectComponent("testProject", "testComponent")) + .isInstanceOf(ComponentNotFoundException.class) + .hasMessage("Component 'testComponent' not found for project 'testProject'"); + verify(marketplaceExternalService).getProjectComponent("testProject", "testComponent"); } @Test - void testCreateProjectComponent_whenSuccess_thenReturnCorrectComponent() throws Exception { - ProjectComponent testComponent = buildTestMarketplaceComponent(); - CreateComponentRequest testRequest = buildTestCreateComponentRequest(); + void create_project_component_returns_mapped_component_when_marketplace_creates_component() { + ProjectComponent marketplaceComponent = buildTestMarketplaceComponent(); + CreateComponentRequest request = buildTestCreateComponentRequest(); - when(marketplaceExternalService.createProjectComponent(anyString(), any(List.class))) - .thenReturn(testComponent); + when(marketplaceExternalService.createProjectComponent(eq("testProject"), any(List.class))) + .thenReturn(marketplaceComponent); - Component retrievedComponent = componentsFacade.createProjectComponent("testId", testRequest); - assertThat(retrievedComponent.getId()).isEqualTo(testComponent.getComponentId()); - assertThat(retrievedComponent.getStatus()).isEqualTo(testComponent.getStatus()); - } + Component createdComponent = componentsFacade.createProjectComponent("testProject", request); + assertThat(createdComponent).isNotNull(); + assertThat(createdComponent.getId()).isEqualTo(marketplaceComponent.getComponentId().toString()); + assertThat(createdComponent.getStatus()).isEqualTo(ComponentsStatusDTO.RUNNING); + verify(marketplaceExternalService).createProjectComponent(eq("testProject"), any(List.class)); + } @Test - void testCreateProjectComponent_whenFailure_thenReturnNull() throws Exception { - CreateComponentRequest testRequest = buildTestCreateComponentRequest(); + void create_project_component_throws_creation_exception_when_marketplace_returns_null() { + CreateComponentRequest request = buildTestCreateComponentRequest(); - when(marketplaceExternalService.createProjectComponent(anyString(), any(List.class))) + when(marketplaceExternalService.createProjectComponent(eq("testProject"), any(List.class))) .thenReturn(null); - Component retrievedComponent = componentsFacade.createProjectComponent("testId", testRequest); - assertThat(retrievedComponent).isNull(); + assertThatThrownBy(() -> componentsFacade.createProjectComponent("testProject", request)) + .isInstanceOf(ComponentCreationException.class) + .hasMessage("Failed to create component for project 'testProject'"); + verify(marketplaceExternalService).createProjectComponent(eq("testProject"), any(List.class)); } } \ No newline at end of file diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java index 14cc84f..0b81f7b 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java @@ -1,14 +1,13 @@ package org.opendevstack.apiservice.project.util; -import org.opendevstack.apiservice.externalservice.marketplace.model.CreateComponentParameter; import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; import org.opendevstack.apiservice.project.model.Component; +import org.opendevstack.apiservice.project.model.ComponentsStatusDTO; import org.opendevstack.apiservice.project.model.CreateComponentRequest; -import org.opendevstack.apiservice.project.model.CreateComponentResponse; -import org.springframework.http.HttpStatus; +import org.opendevstack.apiservice.project.model.EnvironmentsDTO; -import java.util.ArrayList; -import java.util.List; +import java.util.HashMap; +import java.util.UUID; public class TestObjectsBuilder { @@ -19,14 +18,17 @@ public static Component buildTestComponent() { Component component = new Component(); component.setId("testId"); component.setName("testComponentName"); - component.environment("testEnv"); + component.setEnvironment(EnvironmentsDTO.DEV); + component.setStatus(ComponentsStatusDTO.RUNNING); component.setComponentType("testComponentType"); + component.setParams(new HashMap<>()); return component; } public static ProjectComponent buildTestMarketplaceComponent() { ProjectComponent component = new ProjectComponent(); - component.setComponentId("testComponentId"); + component.setComponentId(UUID.randomUUID()); + component.setStatus("RUNNING"); component.setCanBeDeleted(false); component.setComponentUrl("http://test.component.url"); return component; @@ -34,29 +36,9 @@ public static ProjectComponent buildTestMarketplaceComponent() { public static CreateComponentRequest buildTestCreateComponentRequest() { CreateComponentRequest request = new CreateComponentRequest(); - request.setName("testComponentName"); + request.setName("testcomponent"); request.setProductId("testProductId"); + request.setParams(new HashMap<>()); return request; } - - public static List buildTestMarketplaceCreateComponentParameters() { - List parameters = new ArrayList<>(); - parameters.add(new CreateComponentParameter("name", "string", "testComponentName")); - parameters.add(new CreateComponentParameter("productId", "string", "testProductId")); - return parameters; - } - - public static CreateComponentResponse buildTestCreateComponentResponseSuccess(String componentId, String projectId) { - CreateComponentResponse response = new CreateComponentResponse(); - response.setErrorCode(HttpStatus.CREATED.value()); - response.setMessage(componentId + " component created successfully in project " + projectId); - return response; - } - - public static CreateComponentResponse buildTestCreateComponentResponseFailure(String projectId) { - CreateComponentResponse response = new CreateComponentResponse(); - response.setErrorCode(HttpStatus.INTERNAL_SERVER_ERROR.value()); - response.setMessage("Failed to create component for project '" + projectId + "'"); - return response; - } } diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java index 6f76d9e..e866b9b 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java @@ -81,7 +81,7 @@ public ResponseEntity handleValidationException(ProjectVa response.setLocation(ProjectController.API_BASE_PATH); response.setError(HttpStatus.BAD_REQUEST.getReasonPhrase()); response.setErrorKey(errorKey.getKey()); - response.setMessage(errorKey.getMessage()); + response.setMessage(ex.getMessage()); // Needs to get the full message with additional info provided. return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java index fd7904b..2b1718a 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java @@ -2,10 +2,10 @@ public enum ErrorKey { - OK("000", "Success"), + OK("000", ErrorMessage.SUCCESS), PRODUCT_NOT_FOUND("001", ErrorMessage.NOT_FOUND), ACCESS_DENIED("002", ErrorMessage.FORBIDDEN), - INTERNAL_ERROR("003", "Internal error"), + INTERNAL_ERROR("003", ErrorMessage.INTERNAL_ERROR), INVALID_AUTH_HEADER("004", ErrorMessage.BAD_REQUEST), MISSING_AUTH_HEADER("005", ErrorMessage.BAD_REQUEST), INVALID_PARAMETERS("006", ErrorMessage.BAD_REQUEST), @@ -13,26 +13,26 @@ public enum ErrorKey { ONLY_INVITED_PROJECT("008", ErrorMessage.FORBIDDEN), ONCE_PER_PROJECT("009", ErrorMessage.FORBIDDEN), COMPONENT_PARAM_NOT_MEET_REGEX("010", ErrorMessage.BAD_REQUEST), - INVALID_LOCATION("011", ErrorMessage.BAD_REQUEST), + INVALID_LOCATION("011", ErrorMessage.INVALID_LOCATION), PROJECT_NOT_FOUND("012", ErrorMessage.NOT_FOUND), COMPONENT_NOT_FOUND("013", ErrorMessage.NOT_FOUND), BAD_REQUEST_BODY("014", ErrorMessage.BAD_REQUEST), FORBIDDEN("015", ErrorMessage.FORBIDDEN), - DUPLICATE_RECORD("016", "Record already exists"), + DUPLICATE_RECORD("016", ErrorMessage.RECORD_ALREADY_EXISTS), COMPONENT_PARAM_INVALID_FORMAT("017", ErrorMessage.BAD_REQUEST), - PROJECT_KEY_INVALID_FORMAT("018", "projectKey not met the pattern ^[A-Z] {2}[A-Z0-9] {1,8}$"), - PROJECT_NAME_INVALID_FORMAT("019", "projectName not met the pattern ^[A-Za-z0-9 ] {0,80}$"), - PROJECT_DESCRIPTION_INVALID_FORMAT("020", "projectDescription not met the pattern ^.{0,255}$"), - PROJECT_OWNER_INVALID_FORMAT("021", "projectOwner not met the pattern ^[a-z]{1,10}$"), - PROJECT_X2ACCOUNT_INVALID_FORMAT("022", "projectX2Account not met the pattern ^x2[a-zA-Z0-9]{0,13}$"), - BAD_REQUEST_FLAVOR_CONFIG_ITEM("023", "Project flavour and config item cannot be both null"), - MANDATORY_OWNER("024", "Owner must be present if the X2 account is present"), - PROJECT_ALREADY_EXISTS("025", "Project already exists"), - PROJECT_SAME_PROJECT_NAME_ALREADY_EXISTS("026", "Project with same project name already exists"), - CLIENT_APP_NOT_REGISTERED("027", "ClientApp not registered, manual registration required"), + PROJECT_KEY_INVALID_FORMAT("018", ErrorMessage.PROJECT_KEY_NOT_MET_THE_PATTERN), + PROJECT_NAME_INVALID_FORMAT("019", ErrorMessage.PROJECT_NAME_NOT_MET_THE_PATTERN), + PROJECT_DESCRIPTION_INVALID_FORMAT("020", ErrorMessage.PROJECT_DESCRIPTION_NOT_MET_THE_PATTERN), + PROJECT_OWNER_INVALID_FORMAT("021", ErrorMessage.PROJECT_OWNER_NOT_MET_THE_PATTERN), + PROJECT_X2ACCOUNT_INVALID_FORMAT("022", ErrorMessage.PROJECT_X_2_ACCOUNT_NOT_MET_THE_PATTERN), + BAD_REQUEST_FLAVOR_CONFIG_ITEM("023", ErrorMessage.PROJECT_FLAVOUR_AND_CONFIG_ITEM_CANNOT_BE_BOTH_NULL), + MANDATORY_OWNER("024", ErrorMessage.OWNER_MUST_BE_PRESENT_IF_THE_X_2_ACCOUNT_IS_PRESENT), + PROJECT_ALREADY_EXISTS("025", ErrorMessage.PROJECT_ALREADY_EXISTS), + PROJECT_SAME_PROJECT_NAME_ALREADY_EXISTS("026", ErrorMessage.PROJECT_WITH_SAME_PROJECT_NAME_ALREADY_EXISTS), + CLIENT_APP_NOT_REGISTERED("027", ErrorMessage.CLIENT_APP_NOT_REGISTERED_MANUAL_REGISTRATION_REQUIRED), INVALID_PROJECT_FLAVOR("028", ErrorMessage.BAD_REQUEST), INVALID_CONFIG_ITEM("029", ErrorMessage.BAD_REQUEST); - + private String key; private String message; diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java index 767ab2e..5547bbd 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java @@ -5,6 +5,21 @@ public class ErrorMessage { public static final String NOT_FOUND = "Not Found"; public static final String FORBIDDEN = "Forbidden"; public static final String BAD_REQUEST = "Bad Request"; + public static final String INTERNAL_ERROR = "Internal error"; + public static final String SUCCESS = "Success"; + + public static final String INVALID_LOCATION = "Incorrect location. Valid locations are:"; + public static final String RECORD_ALREADY_EXISTS = "Record already exists"; + public static final String PROJECT_KEY_NOT_MET_THE_PATTERN = "projectKey not met the pattern ^[A-Z] {2}[A-Z0-9] {1,8}$"; + public static final String PROJECT_NAME_NOT_MET_THE_PATTERN = "projectName not met the pattern ^[A-Za-z0-9 ] {0,80}$"; + public static final String PROJECT_DESCRIPTION_NOT_MET_THE_PATTERN = "projectDescription not met the pattern ^.{0,255}$"; + public static final String PROJECT_OWNER_NOT_MET_THE_PATTERN = "projectOwner not met the pattern ^[a-z]{1,10}$"; + public static final String PROJECT_X_2_ACCOUNT_NOT_MET_THE_PATTERN = "projectX2Account not met the pattern ^x2[a-zA-Z0-9]{0,13}$"; + public static final String PROJECT_FLAVOUR_AND_CONFIG_ITEM_CANNOT_BE_BOTH_NULL = "Project flavour and config item cannot be both null"; + public static final String OWNER_MUST_BE_PRESENT_IF_THE_X_2_ACCOUNT_IS_PRESENT = "Owner must be present if the X2 account is present"; + public static final String PROJECT_ALREADY_EXISTS = "Project already exists"; + public static final String PROJECT_WITH_SAME_PROJECT_NAME_ALREADY_EXISTS = "Project with same project name already exists"; + public static final String CLIENT_APP_NOT_REGISTERED_MANUAL_REGISTRATION_REQUIRED = "ClientApp not registered, manual registration required"; private ErrorMessage() { // prevent instantiation diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectValidationException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectValidationException.java index 8b656e5..2694cfa 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectValidationException.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectValidationException.java @@ -11,5 +11,10 @@ public ProjectValidationException(ErrorKey errorKey) { super(errorKey.getMessage()); this.errorKey = errorKey; } + + public ProjectValidationException(ErrorKey errorKey, String additionalMessage) { + super(errorKey.getMessage() + " " + additionalMessage); + this.errorKey = errorKey; + } } diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java b/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java index effe572..f3ea98e 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java @@ -56,7 +56,8 @@ private void validateLocation(CreateProjectRequest request) { } if (!locations.contains(location)) { - throw new ProjectValidationException(ErrorKey.INVALID_LOCATION); + throw new ProjectValidationException(ErrorKey.INVALID_LOCATION, + String.join(", ", locations)); } } } diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java index 45435a0..83942c6 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java @@ -22,6 +22,7 @@ import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; +import java.util.List; import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -96,6 +97,25 @@ void handle_validation_exception_returns_bad_request_response_for_missing_flavor assertNull(result.getBody().getErrorDescription()); } + @Test + void handle_validation_exception_preserves_additional_message_content() { + List validLocations = List.of("MADRID", "BARCELONA", "SANT_CUGAT"); + ProjectValidationException exception = new ProjectValidationException( + ErrorKey.INVALID_LOCATION, + String.join(",", validLocations) + ); + + ResponseEntity result = sut.handleValidationException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals("011", result.getBody().getErrorKey()); + assertEquals( + "Incorrect location. Valid locations are: MADRID,BARCELONA,SANT_CUGAT", + result.getBody().getMessage() + ); + } + @Test void handle_method_argument_not_valid_exception_returns_bad_request_response_for_request_body_validation_errors() { CreateProjectRequest target = new CreateProjectRequest(); diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java index 9da0b9a..9fd93c7 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java @@ -37,7 +37,7 @@ public AuthorizationDecision evaluate(PolicyContext context) { } String requestedFlavor = (String) body.get("projectFlavor"); - if (requestedFlavor == null) { + if (requestedFlavor == null || requestedFlavor.trim().isEmpty()) { return AuthorizationDecision.ABSTAIN; } diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/ProjectComponent.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/ProjectComponent.java index 89b640b..5ca9c00 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/ProjectComponent.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/ProjectComponent.java @@ -4,13 +4,23 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.util.UUID; + @NoArgsConstructor @AllArgsConstructor @Data public class ProjectComponent { - private String componentId; + private UUID componentId; + private String name; + private String productDescription; + private String productName; + private String productId; + private String environment; private String status; + private String resultTraceback; + private String repositoryURL; + private String componentType; private boolean canBeDeleted; private String logoUrl; private String componentUrl; diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceMockImpl.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceMockImpl.java index 2301a76..ec1007c 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceMockImpl.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceMockImpl.java @@ -6,10 +6,10 @@ import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; import org.springframework.stereotype.Service; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; @Service @Slf4j @@ -31,17 +31,27 @@ public ProjectComponent getProjectComponent(String projectId, String componentId public ProjectComponent createProjectComponent(String projectId, List createComponentParams) { log.info("Creating component for project '" + projectId + "'" + " with request: " + createComponentParams); ProjectComponent mockComponent = new ProjectComponent(); - mockComponent.setComponentId(generateNextId()); + mockComponent.setComponentId(UUID.randomUUID()); mockComponent.setCanBeDeleted(true); mockComponent.setStatus("CREATING"); - ComposedId composedId = new ComposedId(projectId, mockComponent.getComponentId()); + mockComponent.setName(extractParam(createComponentParams, "component_id")); + mockComponent.setProductId(extractParam(createComponentParams, "component_type")); + mockComponent.setProductName("Mock Product"); + mockComponent.setProductDescription("Mock product description"); + mockComponent.setEnvironment("DEV"); + mockComponent.setComponentType("ODS"); + ComposedId composedId = new ComposedId(projectId, mockComponent.getComponentId().toString()); mockComponentsCache.put(composedId, mockComponent); return mockComponent; } - private String generateNextId() { - return "mock-component-id-" + (mockComponentsCache.size() + 1); + private String extractParam(List params, String key) { + return params.stream() + .filter(p -> key.equals(p.getName())) + .map(CreateComponentParameter::getValue) + .findFirst() + .orElse(null); } class ComposedId { diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java index a448376..0214925 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java @@ -12,9 +12,12 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import lombok.extern.slf4j.Slf4j; + import java.util.Comparator; import java.util.Set; +@Slf4j @Service public class ProjectExistenceServiceImpl implements ProjectExistenceService { @@ -31,7 +34,6 @@ public ProjectExistenceServiceImpl(BitbucketService bitbucketService, JiraServic this.openshiftService = openshiftService; this.projectService = projectService; } - @Override public boolean isProjectFound(String projectKey) throws ProjectExistenceServiceException { try { @@ -46,6 +48,7 @@ public boolean isProjectFound(String projectKey) throws ProjectExistenceServiceE } catch (OpenshiftException e) { throw new ProjectExistenceServiceException("Failed to check project in Openshift", e); } catch (RuntimeException e) { + log.error("Unexpected error while checking project existence for key '{}'", projectKey, e); throw new ProjectExistenceServiceException("Unexpected error while checking project existence", e); } }