diff --git a/.gitignore b/.gitignore index c765529..46a7ede 100644 --- a/.gitignore +++ b/.gitignore @@ -97,5 +97,7 @@ api-project/src/main/java/org/opendevstack/apiservice/project/api api-project/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 + **/.openapi-generator 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 c85d72a..4e331b4 100644 --- a/api-project-component-v0/openapi/api-project-component-v0.yaml +++ b/api-project-component-v0/openapi/api-project-component-v0.yaml @@ -90,7 +90,6 @@ paths: description: Component id schema: type: string - format: uuid responses: '200': description: Component information 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 28fa4a0..9e6cb70 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 @@ -22,6 +22,10 @@ public static CreateComponentResponse forbidden(String path, String message, Com return buildResponse(HttpStatus.FORBIDDEN, errorKey, path, message); } + public static CreateComponentResponse conflict(String path, String message, ComponentErrorKey errorKey) { + return buildResponse(HttpStatus.CONFLICT, errorKey, path, message); + } + public static CreateComponentResponse notFound(String path, String message, ComponentErrorKey errorKey) { return buildResponse(HttpStatus.NOT_FOUND, errorKey, path, message); } 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 d288cae..e168e06 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 @@ -2,8 +2,8 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; 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; @@ -15,8 +15,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.UUID; - @RestController @AllArgsConstructor @Slf4j @@ -31,27 +29,34 @@ public class ProjectComponentsController implements ProjectComponentsApi { @Override public ResponseEntity createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { - Component component = componentsFacade.createProjectComponent(projectId, createComponentRequest); - if (component == null) { - throw new ComponentCreationException(String.format("Failed to create component for project '%s'", projectId)); - } + componentsFacade.provisionProjectComponent(projectId, createComponentRequest); - log.info("Created component {} for project id {} and request {}", component, projectId, createComponentRequest); + log.info("Created component '{}' for project '{}'", createComponentRequest.getName(), projectId); return componentResponseMapper.toResponseEntity( - ComponentsResponseFactory.entityCreated(projectId, component.getId()) + ComponentsResponseFactory.entityCreated(projectId, createComponentRequest.getName()) ); + } @Override - public ResponseEntity getProjectComponent(String projectId, UUID componentId) { - Component component = componentsFacade.getProjectComponent(projectId, componentId.toString()); - if (component == null) { + public ResponseEntity getProjectComponent(String projectId, String componentId) { + try { + + Component component = componentsFacade.getProjectComponent(projectId, componentId); + 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); + } catch (MarketplaceException e) { //TODO use error handler + log.error("Error while retrieving component '{}' for project '{}': {}", componentId, projectId, e.getMessage(), e); throw new ComponentNotFoundException( - String.format("Component '%s' not found for project '%s'", componentId, projectId) + String.format("Failed to retrieve component '%s' for project '%s': %s", componentId, projectId, e.getMessage()), e ); } - - 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 index f561d3d..f83ac96 100644 --- 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 @@ -4,6 +4,7 @@ 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.ComponentAlreadyExistsException; import org.opendevstack.apiservice.project.exception.ComponentCreationException; import org.opendevstack.apiservice.project.exception.ComponentErrorKey; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; @@ -138,6 +139,22 @@ public ResponseEntity handleComponentCreationException( return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); } + @ExceptionHandler(ComponentAlreadyExistsException.class) + public ResponseEntity handleComponentAlreadyExistsException( + ComponentAlreadyExistsException ex, + HttpServletRequest request) { + + log.warn("Component already exists: {}", ex.getMessage()); + + CreateComponentResponse response = ComponentsResponseFactory.conflict( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.INVALID_PARAMETERS + ); + + return ResponseEntity.status(HttpStatus.CONFLICT).body(response); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleGenericException( Exception ex, diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentAlreadyExistsException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentAlreadyExistsException.java new file mode 100644 index 0000000..8435586 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentAlreadyExistsException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentAlreadyExistsException extends RuntimeException { + + public ComponentAlreadyExistsException(String message) { + super(message); + } + + public ComponentAlreadyExistsException(String message, Exception e) { + super(message, e); + } +} \ No newline at end of file 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 index f035918..dce8484 100644 --- 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 @@ -5,4 +5,8 @@ public class ComponentCreationException extends RuntimeException { public ComponentCreationException(String message) { super(message); } + + public ComponentCreationException(String message, Exception e) { + super(message, e); + } } 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 index 8ee5ffc..38caa87 100644 --- 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 @@ -5,4 +5,8 @@ public class ComponentNotFoundException extends RuntimeException { public ComponentNotFoundException(String message) { super(message); } + + public ComponentNotFoundException(String message, Exception e) { + super(message, e); + } } 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 42bab0e..f590b55 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 @@ -2,15 +2,18 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.opendevstack.apiservice.externalservice.marketplace.model.CreateComponentParameter; -import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentInfo; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionParameter; import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException; 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; import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; import java.util.List; @@ -23,8 +26,8 @@ public class ComponentsFacade { private final MarketplaceMapper marketplaceMapper; - public Component getProjectComponent(String projectId, String componentId) { - ProjectComponent marketplaceComponent = marketplaceExternalService.getProjectComponent(projectId, componentId); + public Component getProjectComponent(String projectId, String componentId) throws MarketplaceException { + ProjectComponentInfo marketplaceComponent = marketplaceExternalService.getProjectComponent(projectId, componentId); if (marketplaceComponent == null) { log.info("Marketplace component with id {} not found", componentId); throw new ComponentNotFoundException( @@ -34,15 +37,53 @@ public Component getProjectComponent(String projectId, String componentId) { return marketplaceMapper.mapMarketplaceComponentToV0Component(marketplaceComponent); } - public Component createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { - List createComponentParameterList = marketplaceMapper.mapCreateComponentRequestToCreateComponentParameterList(createComponentRequest); - ProjectComponent marketplaceComponent = marketplaceExternalService.createProjectComponent(projectId, createComponentParameterList); - if (marketplaceComponent == null) { - log.error("Failed to create component in marketplace for project with id {}", projectId); + public void provisionProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { + try { + List createComponentParameterList = marketplaceMapper.mapCreateComponentRequestToCreateComponentParameterList(createComponentRequest); + boolean success = marketplaceExternalService.provisionProjectComponent(projectId, createComponentParameterList); + if (!success) { + log.error("Failed to create component in marketplace for project with id {}", projectId); + throw new ComponentCreationException( + String.format("Failed to create component for project '%s'", projectId) + ); + } + } catch (MarketplaceException e) { + if (isConflictCause(e)) { + throw new ComponentAlreadyExistsException(e.getMessage(), e); + } throw new ComponentCreationException( - String.format("Failed to create component for project '%s'", projectId) + String.format("Failed to create component for project '%s': %s", projectId, e.getMessage()), e ); } - return marketplaceMapper.mapMarketplaceComponentToV0Component(marketplaceComponent); + } + + private boolean isConflictCause(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof HttpClientErrorException.Conflict) { + return true; + } + current = current.getCause(); + } + return false; + } + + public Boolean deleteProjectComponent(String projectId, String componentId) { + try { + return marketplaceExternalService.deleteProjectComponent(projectId, componentId); + } catch (MarketplaceException e) { + log.error("Failed to delete component with id {} for project with id {}", componentId, projectId, e); + return false; + } + } + + public boolean registerProjectComponent(String projectId, String componentId) { + try { + marketplaceExternalService.registerProjectComponent(projectId, componentId); + return true; + } catch (MarketplaceException e) { + log.error("Failed to register component in marketplace for project with id {}", projectId, e); + return false; + } } } 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 8e84500..a81d099 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 @@ -4,70 +4,48 @@ 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.externalservice.marketplace.openapi.model.ProjectComponentInfo; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionParameter; 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.ArrayList; import java.util.List; import java.util.Map; -import java.util.UUID; @Mapper(componentModel = "spring") public interface MarketplaceMapper { - @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 = "id", source = "componentId") @Mapping(target = "resultTraceback", ignore = true) - Component mapMarketplaceComponentToV0Component(ProjectComponent source); + Component mapMarketplaceComponentToV0Component(ProjectComponentInfo source); - default List mapCreateComponentRequestToCreateComponentParameterList(CreateComponentRequest createComponentRequest) { - if (createComponentRequest == null || createComponentRequest.getParams() == null) { + default List mapCreateComponentRequestToCreateComponentParameterList(CreateComponentRequest createComponentRequest) { + if (createComponentRequest == null) { return List.of(); } - return mapEntriesToCreateComponentParameterList(createComponentRequest.getParams().entrySet().stream().toList()); + List parameters = new ArrayList<>(); + parameters.add(createParameter("component_id", createComponentRequest.getName(), "string")); + parameters.add(createParameter("component_type", createComponentRequest.getProductId(), "string")); + + if (createComponentRequest.getParams() != null && !createComponentRequest.getParams().isEmpty()) { + parameters.addAll(mapEntriesToCreateComponentParameterList(createComponentRequest.getParams().entrySet().stream().toList())); + } + + return parameters; + } + + default ProvisionActionParameter createParameter(String name, String value, String type) { + return new ProvisionActionParameter().name(name).type(type).value(value); } @IterableMapping(qualifiedByName = "toCreateComponentParameter") - List mapEntriesToCreateComponentParameterList(List> entries); + 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; - } - } - - @Named("toEnvironment") - default EnvironmentsDTO toEnvironment(String sourceEnv) { - if (sourceEnv == null || sourceEnv.isBlank()) { - return null; - } - try { - return EnvironmentsDTO.fromValue(sourceEnv); - } catch (IllegalArgumentException ex) { - return null; - } - } + ProvisionActionParameter toCreateComponentParameter(Map.Entry entry); } 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 0b67225..8142c12 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 @@ -6,7 +6,7 @@ import org.mapstruct.factory.Mappers; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import org.opendevstack.apiservice.project.exception.ComponentCreationException; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; import org.opendevstack.apiservice.project.facade.ComponentsFacade; import org.opendevstack.apiservice.project.mapper.ComponentResponseMapper; @@ -16,12 +16,11 @@ 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.eq; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestComponent; @@ -50,14 +49,11 @@ void tearDown() throws Exception { } @Test - void create_project_component_returns_ok_when_component_is_created() { + void create_project_component_returns_ok_with_component_name_in_path() { String projectId = "testProjectId"; - CreateComponentRequest request = buildTestCreateComponentRequest(); - Component createdComponent = buildTestComponent(); - createdComponent.setId("component-123"); + CreateComponentRequest request = buildTestCreateComponentRequest(); // name = "testcomponent" - when(componentsFacade.createProjectComponent(eq(projectId), any(CreateComponentRequest.class))) - .thenReturn(createdComponent); + doNothing().when(componentsFacade).provisionProjectComponent(eq(projectId), any(CreateComponentRequest.class)); ResponseEntity response = projectComponentsController.createProjectComponent(projectId, request); @@ -66,63 +62,49 @@ void create_project_component_returns_ok_when_component_is_created() { 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 create_project_component_returns_internal_error_when_component_creation_returns_null() { - String projectId = "testProjectId"; - CreateComponentRequest request = buildTestCreateComponentRequest(); - - when(componentsFacade.createProjectComponent(eq(projectId), any(CreateComponentRequest.class))) - .thenReturn(null); - - assertThatThrownBy(() -> projectComponentsController.createProjectComponent(projectId, request)) - .isInstanceOf(ComponentCreationException.class) - .hasMessage("Failed to create component for project 'testProjectId'"); - verify(componentsFacade).createProjectComponent(projectId, request); + assertThat(response.getBody().getPath()).isEqualTo("/api/pub/v0/projects/testProjectId/components/testcomponent"); + verify(componentsFacade).provisionProjectComponent(projectId, request); } @Test - void create_project_component_propagates_exception_when_facade_throws_exception() { + 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")); + org.mockito.Mockito.doThrow(new RuntimeException("boom")) + .when(componentsFacade).provisionProjectComponent(eq(projectId), any(CreateComponentRequest.class)); assertThatThrownBy(() -> projectComponentsController.createProjectComponent(projectId, request)) .isInstanceOf(RuntimeException.class) .hasMessage("boom"); - verify(componentsFacade).createProjectComponent(projectId, request); + verify(componentsFacade).provisionProjectComponent(projectId, request); } @Test - void get_project_component_returns_ok_when_component_exists() { + void get_project_component_returns_ok_when_component_exists() throws MarketplaceException { String projectId = "projectId"; - UUID componentId = UUID.randomUUID(); + String componentId = "test-component-one"; Component testComponent = buildTestComponent(); - when(componentsFacade.getProjectComponent(projectId, componentId.toString())).thenReturn(testComponent); + when(componentsFacade.getProjectComponent(projectId, componentId)).thenReturn(testComponent); ResponseEntity response = projectComponentsController.getProjectComponent(projectId, componentId); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody()).isEqualTo(testComponent); - verify(componentsFacade).getProjectComponent(projectId, componentId.toString()); + verify(componentsFacade).getProjectComponent(projectId, componentId); } @Test - void get_project_component_throws_not_found_when_component_does_not_exist() { + void get_project_component_throws_not_found_when_component_does_not_exist() throws MarketplaceException { String projectId = "projectId"; - UUID componentId = UUID.randomUUID(); + String componentId = "test-component-one"; - when(componentsFacade.getProjectComponent(projectId, componentId.toString())).thenReturn(null); + when(componentsFacade.getProjectComponent(projectId, componentId)).thenReturn(null); assertThatThrownBy(() -> projectComponentsController.getProjectComponent(projectId, componentId)) .isInstanceOf(ComponentNotFoundException.class) .hasMessage("Component '" + componentId + "' not found for project 'projectId'"); - verify(componentsFacade).getProjectComponent(projectId, componentId.toString()); + verify(componentsFacade).getProjectComponent(projectId, componentId); } } \ 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 index e6bced2..92a0601 100644 --- 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 @@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException; import org.opendevstack.apiservice.project.exception.ComponentCreationException; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; import org.opendevstack.apiservice.project.model.CreateComponentResponse; @@ -134,6 +135,20 @@ void handle_component_creation_exception_returns_internal_server_error() { assertThat(response.getBody().getMessage()).isEqualTo("Creation failed"); } + @Test + void handle_component_already_exists_exception_returns_conflict() { + ComponentAlreadyExistsException exception = new ComponentAlreadyExistsException( + "This component name already exists, please choose another name."); + + ResponseEntity response = handler.handleComponentAlreadyExistsException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getHttpStatus()).isEqualTo(HttpStatus.CONFLICT.name()); + assertThat(response.getBody().getErrorKey()).isEqualTo("006"); + assertThat(response.getBody().getMessage()).isEqualTo("This component name already exists, please choose another name."); + } + @Test void handle_generic_exception_returns_internal_server_error() { RuntimeException exception = new RuntimeException("boom"); 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 978dae8..fc9a465 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 @@ -3,29 +3,35 @@ 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.MockitoAnnotations; -import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; +import org.mockito.junit.jupiter.MockitoExtension; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentInfo; import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException; 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 org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpClientErrorException; 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.anyList; 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); @@ -49,8 +55,8 @@ void tearDown() throws Exception { } @Test - void get_project_component_returns_mapped_component_when_marketplace_returns_data() { - ProjectComponent marketplaceComponent = buildTestMarketplaceComponent(); + void get_project_component_returns_mapped_component_when_marketplace_returns_data() throws MarketplaceException { + ProjectComponentInfo marketplaceComponent = buildTestMarketplaceComponent(); when(marketplaceExternalService.getProjectComponent("testProject", "testComponent")) .thenReturn(marketplaceComponent); @@ -58,13 +64,13 @@ void get_project_component_returns_mapped_component_when_marketplace_returns_dat Component retrievedComponent = componentsFacade.getProjectComponent("testProject", "testComponent"); assertThat(retrievedComponent).isNotNull(); - assertThat(retrievedComponent.getId()).isEqualTo(marketplaceComponent.getComponentId().toString()); + assertThat(retrievedComponent.getId()).isNotNull(); assertThat(retrievedComponent.getStatus()).isEqualTo(ComponentsStatusDTO.RUNNING); verify(marketplaceExternalService).getProjectComponent("testProject", "testComponent"); } @Test - void get_project_component_throws_not_found_when_marketplace_returns_null() { + void get_project_component_throws_not_found_when_marketplace_returns_null() throws MarketplaceException { when(marketplaceExternalService.getProjectComponent("testProject", "testComponent")) .thenReturn(null); @@ -75,31 +81,51 @@ void get_project_component_throws_not_found_when_marketplace_returns_null() { } @Test - void create_project_component_returns_mapped_component_when_marketplace_creates_component() { - ProjectComponent marketplaceComponent = buildTestMarketplaceComponent(); + void create_project_component_returns_mapped_component_when_marketplace_creates_component() throws MarketplaceException { CreateComponentRequest request = buildTestCreateComponentRequest(); - when(marketplaceExternalService.createProjectComponent(eq("testProject"), any(List.class))) - .thenReturn(marketplaceComponent); + when(marketplaceExternalService.provisionProjectComponent(eq("testProject"), anyList())) + .thenReturn(true); //TODO fix this to return more info - Component createdComponent = componentsFacade.createProjectComponent("testProject", request); + componentsFacade.provisionProjectComponent("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)); + //TODO fix this to return more info and assert on it +// assertThat(createdComponent).isNotNull(); +// assertThat(createdComponent.getId()).isEqualTo(marketplaceComponent.getComponentId().toString()); +// assertThat(createdComponent.getStatus()).isEqualTo(ComponentsStatusDTO.RUNNING); + verify(marketplaceExternalService).provisionProjectComponent(eq("testProject"), anyList()); } @Test - void create_project_component_throws_creation_exception_when_marketplace_returns_null() { + void create_project_component_throws_creation_exception_when_marketplace_returns_null() throws MarketplaceException { CreateComponentRequest request = buildTestCreateComponentRequest(); - when(marketplaceExternalService.createProjectComponent(eq("testProject"), any(List.class))) - .thenReturn(null); + when(marketplaceExternalService.provisionProjectComponent(eq("testProject"), anyList())) + .thenReturn(false); //TODO fix this to return more info - assertThatThrownBy(() -> componentsFacade.createProjectComponent("testProject", request)) + assertThatThrownBy(() -> componentsFacade.provisionProjectComponent("testProject", request)) .isInstanceOf(ComponentCreationException.class) .hasMessage("Failed to create component for project 'testProject'"); - verify(marketplaceExternalService).createProjectComponent(eq("testProject"), any(List.class)); + verify(marketplaceExternalService).provisionProjectComponent(eq("testProject"), anyList()); } + + @Test + void create_project_component_throws_already_exists_when_marketplace_returns_conflict() throws MarketplaceException { + CreateComponentRequest request = buildTestCreateComponentRequest(); + HttpClientErrorException conflict = HttpClientErrorException.Conflict.create( + HttpStatus.CONFLICT, + "Conflict", + HttpHeaders.EMPTY, + new byte[0], + null + ); + + when(marketplaceExternalService.provisionProjectComponent(eq("testProject"), anyList())) + .thenThrow(new MarketplaceException("This component name already exists, please choose another name.", conflict)); + + assertThatThrownBy(() -> componentsFacade.provisionProjectComponent("testProject", request)) + .isInstanceOf(ComponentAlreadyExistsException.class) + .hasMessage("This component name already exists, please choose another name."); + verify(marketplaceExternalService).provisionProjectComponent(eq("testProject"), anyList()); + } } \ 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 0b81f7b..1abb7b2 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,6 +1,7 @@ package org.opendevstack.apiservice.project.util; import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentInfo; import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.ComponentsStatusDTO; import org.opendevstack.apiservice.project.model.CreateComponentRequest; @@ -25,9 +26,9 @@ public static Component buildTestComponent() { return component; } - public static ProjectComponent buildTestMarketplaceComponent() { - ProjectComponent component = new ProjectComponent(); - component.setComponentId(UUID.randomUUID()); + public static ProjectComponentInfo buildTestMarketplaceComponent() { + ProjectComponentInfo component = new ProjectComponentInfo(); + component.setComponentId(UUID.randomUUID().toString()); component.setStatus("RUNNING"); component.setCanBeDeleted(false); component.setComponentUrl("http://test.component.url"); diff --git a/external-service-marketplace/.openapi-generator-ignore b/external-service-marketplace/.openapi-generator-ignore new file mode 100644 index 0000000..82ac4f7 --- /dev/null +++ b/external-service-marketplace/.openapi-generator-ignore @@ -0,0 +1,43 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md + +api +api/** +gradle +gradle/** +.github +.github/** +pom.xml +**/AndroidManifest.xml +.gitignore +.openapi-generator-ignore +.travis.yml +build.gradle +build.sbt +git_push.sh +gradle.properties +gradlew +gradlew.bat +settings.gradle + diff --git a/external-service-marketplace/openapi/openapi-component_catalog-v1.0.0.yaml b/external-service-marketplace/openapi/openapi-component_catalog-v1.0.0.yaml new file mode 100644 index 0000000..82ca393 --- /dev/null +++ b/external-service-marketplace/openapi/openapi-component_catalog-v1.0.0.yaml @@ -0,0 +1,1261 @@ +openapi: 3.0.3 +info: + title: Component Catalog REST API + version: '1.0.0' + description: > + The Component Catalog API allows clients to retrieve information about CatalogItems, CatalogFilters and Files entities. + + Catalog and File entities also exist internally, but only referenced by id's on requests and not returned on responses. + + **NOTES**: + - The OpenAPI specification file is also used to [generate](https://openapi-generator.tech/) REST client(s) and a server REST API. + - Clients and servers generated from the same OpenAPI specification version are guaranteed to be **compatible**. + contact: + name: EDPCore Team + url: https://confluence.biscrum.com/pages/viewpage.action?spaceKey=EDP&title=Welcome +servers: + - url: http://{baseurl}/v1 + variables: + baseurl: + default: localhost:8080 + description: Default address for a Component Catalog's backend REST API instance. +security: + - bearerAuth: [ ] +tags: + - name: CatalogItems + description: CatalogItems operations. + - name: CatalogFilters + description: CatalogFilters operations. + - name: CatalogItemUserActionMessageDefinitions + description: User actions standardized messages definitions + - name: Files + description: File operations. + - name: SchemaValidations + description: Schema Validations operations. + - name: ProvisionerActions + description: Provisioning notifications from AWX/Provisioner +paths: + /project/{projectKey}/components: + get: + tags: + - Project-components + summary: Returns the information of the project's components in the Bitbucket repository. + operationId: getProjectComponents + parameters: + - name: projectKey + in: path + description: project key. + required: true + schema: + type: string + responses: + "200": + description: A list of Project Component Information + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ProjectComponentInfo' + "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: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /catalog-descriptors: + get: + tags: + - Catalog-descriptors + summary: List of all available Catalog Descriptors. + description: > + Returns a list of all available Catalog Descriptors.
+ operationId: getCatalogDescriptors + responses: + "200": + description: A list of Catalog Descriptors. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CatalogDescriptor' + "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: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /catalogs/{catalogId}: + get: + tags: + - Catalogs + summary: Get a catalog by id. + description: > + Returns a valid catalog. + operationId: getCatalog + parameters: + - name: catalogId + in: path + description: id for the Catalog. + required: true + schema: + type: string + responses: + "200": + description: A Single catalog. + content: + application/json: + schema: + $ref: '#/components/schemas/Catalog' + "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' + "404": + description: Catalog not found + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /catalog-items/slug/{slug}: + get: + tags: + - CatalogItems + summary: Returns the CatalogItem associated to the provided slug. + description: > + Returns the CatalogItem identified by a composite slug with format `{project-key}_{catalog-item-repository-name}`.
+ The separator is the first underscore (`_`); everything after it (the repo name) may itself contain underscores.
+ The project-key is the normalised (lowercase) Bitbucket project key that owns the item's repository. + The catalog-item-repository-name is matched against the Bitbucket repository slug of the item.
+ Returns 404 if no catalog item matches the provided slug. + operationId: getCatalogItemBySlug + parameters: + - name: slug + in: path + description: > + Composite slug with format `{project-key}_{catalog-item-repository-name}`. + The separator is the first underscore; the repo name may contain additional underscores. + Example: `myproject_my-component-repo` + required: true + schema: + type: string + example: 'myproject_my-component-repo' + responses: + "200": + description: The CatalogItem. + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem' + "400": + description: Invalid or malformed slug provided. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "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' + "404": + description: No CatalogItem associated to the provided slug. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "422": + description: Invalid CatalogItem associated to the provided slug. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /catalog-items: + get: + tags: + - CatalogItems + summary: List of all CatalogItems. + description: > + Returns a list of all CatalogItems for the given Catalog identified by catalogId.
+ CatalogItems referenced on a Catalog that are either invalid or non-existent are **excluded** from the response. + operationId: getCatalogItems + parameters: + - name: catalogId + in: query + description: id for the Catalog. + required: true + schema: + type: string + - name: sortByTitle + in: query + description: Sort the returned CatalogItems by title, either in ascending or descending order. + required: true + schema: + $ref: '#/components/schemas/SortOrder' + example: 'asc' + responses: + "200": + description: A list of valid CatalogItems. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CatalogItem' + "400": + description: Invalid parameters provided on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "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: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /catalog-items/{id}: + get: + tags: + - CatalogItems + summary: Returns the CatalogItem associated to the provided id. + description: > + Returns the CatalogItem associated to the provided id, unless: +
    +
  • The id is not associated to any CatalogItem.
  • +
  • Or the associated CatalogItem is invalid and can't be processed to create a response.
  • +
+ operationId: getCatalogItemById + parameters: + - name: id + in: path + description: id for the CatalogItem. + required: true + schema: + type: string + example: 'aSdFam...yCg==' + responses: + "200": + description: The CatalogItem. + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem' + "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' + "404": + description: No CatalogItem associated to the provided id. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "422": + description: Invalid CatalogItem associated to the provided id. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /{projectKey}/catalog-items: + get: + tags: + - CatalogItems + summary: List of all CatalogItems given a project key. + description: > + Returns a list of all CatalogItems for the given Catalog identified by catalogId given a project key.
+ CatalogItems referenced on a Catalog that are either invalid or non-existent are **excluded** from the response. + operationId: getCatalogItemsForProjectKey + parameters: + - name: catalogId + in: query + description: id for the Catalog. + required: true + schema: + type: string + - name: sortByTitle + in: query + description: Sort the returned CatalogItems by title, either in ascending or descending order. + required: true + schema: + $ref: '#/components/schemas/SortOrder' + example: 'asc' + - name: projectKey + in: path + description: project key. + required: true + schema: + type: string + responses: + "200": + description: A list of valid CatalogItems. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CatalogItem' + "400": + description: Invalid parameters provided on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "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: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /{projectKey}/catalog-items/{id}: + get: + tags: + - CatalogItems + summary: Returns the CatalogItem associated to the provided id, given a project key. + description: > + Returns the CatalogItem associated to the provided id, given a project key, unless: +
    +
  • The id is not associated to any CatalogItem.
  • +
  • Or the associated CatalogItem is invalid and can't be processed to create a response.
  • +
  • Project key does not exist or user has no visibility over it
  • +
+ operationId: getCatalogItemByIdForProjectKey + parameters: + - name: id + in: path + description: id for the CatalogItem. + required: true + schema: + type: string + example: 'aSdFam...yCg==' + - name: projectKey + in: path + description: project key. + required: true + schema: + type: string + responses: + "200": + description: The CatalogItem. + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem' + "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' + "404": + description: No CatalogItem associated to the provided id. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "422": + description: Invalid CatalogItem associated to the provided id. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /catalog-filters: + get: + tags: + - CatalogFilters + summary: List of all CatalogItemFilters. + description: > + Returns the list of all CatalogItemFilters for the CatalogItems on the Catalog identified by catalogId.
+ CatalogItemFilters are built based on the contents of the Catalog and its CatalogItems.
+ Catalog or CatalogItems **with errors** will affect the number and/or contents of the returned CatalogItemFilters. + operationId: getCatalogFilters + parameters: + - name: catalogId + in: query + description: id for the Catalog. + required: true + schema: + type: string + example: 'cHJvam...yCg==' + responses: + "200": + description: A list of CatalogItemFilters. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CatalogItemFilter' + "400": + description: Invalid parameters provided on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "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: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /files/{id}/contents: + get: + tags: + - Files + summary: Returns the contents of a File. + description: > + Returns the contents of a File entity associated to the provided id, unless: +
    +
  • The id is not associated to any File.
  • +
  • Or the associated File is invalid (e.g. corrupted) and can't be processed to create a response.
  • +
+ operationId: getFileById + parameters: + - name: id + in: path + description: id for the File. + required: true + schema: + type: string + example: 'cHJvam...yCg==' + - name: format + in: query + description: desired format for the returned File contents, **must** match the actual format. + required: true + schema: + $ref: '#/components/schemas/FileFormat' + example: image + responses: + "200": + description: File contents, either in binary or text format. + content: + "application/octet-stream": + schema: + type: string + format: byte + description: binary file contents. + example: '' + "text/*": + schema: + type: string + description: text file contents. + example: '# About\nThis repository contains the source code for...' + "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' + "404": + description: No File associated to the provided id. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "422": + description: Invalid File associated to the provided id. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /catalog-items/{catalogItemId}/user-actions/{userActionId}/messages-definitions/{messageDefinitionId}: + post: + tags: + - CatalogItemUserActionMessageDefinitions + summary: Get a message definition by id. + description: > + Returns an standard message definition + operationId: getMessageDefinitionByCatalogItemIdAndMessageId + parameters: + - name: catalogItemId + in: path + description: id for the CatalogItem + required: true + schema: + type: string + - name: userActionId + in: path + description: id for the CatalogItemUserAction + required: true + schema: + type: string + - name: messageDefinitionId + in: path + description: id for the CatalogItemUserActionMessageDefinition + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: + type: string + responses: + "200": + description: A single message definition. + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItemUserActionMessageDefinition' + "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' + "404": + description: Catalog not found + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /schema-validation/{className}: + post: + tags: + - SchemaValidations + summary: Validate a yaml against a proper schema. + description: > + Validates the provided Catalog schema against the expected format and structure.
+ Returns a 200 OK response if the schema is valid, otherwise returns a 400 Bad Request with details about the validation errors. + operationId: validateCatalogSchema + parameters: + - name: className + in: path + description: ClassName for the uploaded file, so we can get proper schema for validation. + required: true + schema: + type: string + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + "200": + description: Validation resul. + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationResult' + '400': + description: Invalid input or validation failed + + /provision/{project-key}/{status}: + put: + tags: + - ProvisionerActions + summary: Create new project component + description: > + This endpoint will create a new project component. + operationId: notifyProvisioningStatusUpdate + parameters: + - name: project-key + in: path + description: Project key of the provisioned component. + required: true + schema: + type: string + - name: status + in: path + description: Provisioning status for the component. + required: true + example: CREATING + schema: + type: string + enum: [ CREATING, CREATED, FAILED, DELETING, UNKNOWN ] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisioningStatusUpdateRequest' + responses: + "200": + description: Provisioning status update completed. + "400": + description: Bad request. + "401": + description: Invalid client token on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. + patch: + tags: + - ProvisionerActions + summary: Update an existing project component + description: > + This endpoint receives provisioning status update notifications from AWX. + operationId: notifyProvisioningStatusUpdatePartially + parameters: + - name: project-key + in: path + description: Project key of the provisioned component. + required: true + schema: + type: string + - name: status + in: path + description: Provisioning status for the component. + required: true + example: CREATING + schema: + type: string + enum: [ CREATING, CREATED, FAILED, DELETING, UNKNOWN ] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisioningStatusUpdateRequest' + responses: + "200": + description: Provisioning status update completed. + "400": + description: Bad request. + "401": + description: Invalid client token on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. + + /provision/{project-key}: + delete: + security: + - basicAuth: [] # Enable ONLY basicAuth + tags: + - ProvisionerActions + summary: Delete provision status component from the file + description: > + This endpoint receives provisioning status delete notifications from Component Provisioner. + operationId: deleteProvisioningStatus + parameters: + - name: project-key + in: path + description: Project key of the provisioned component. + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisioningDeleteRequest' + responses: + "200": + description: Project component properly deleted. + "400": + description: Bad request. + "401": + description: Invalid client token on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. + +components: + securitySchemes: + bearerAuth: + type: http + description: > + Authorization via bearer token. + scheme: bearer + bearerFormat: JWT + basicAuth: + type: http + scheme: basic + description: Basic authentication for internal provisioning endpoint + schemas: + Catalog: + properties: + name: + type: string + example: 'catalog-name' + description: + type: string + example: 'A brief description for a catalog' + communityPageId: + type: string + example: 'aSdFam...yCg==' + links: + type: array + items: + $ref: '#/components/schemas/CatalogLink' + tags: + type: array + items: + type: string + example: + - 'tasks' + - 'technologies' + ProjectComponentInfo: + properties: + componentId: + type: string + example: 'edpc-4132-v2' + status: + type: string + example: 'CREATING' + canBeDeleted: + type: boolean + example: true + logoUrl: + type: string + example: https://somepic.jpg + componentUrl: + type: string + example: 'https://bitbucket.com/projects/CATALOGS/repos/project-components/browse/projects' + CatalogDescriptor: + properties: + id: + type: string + example: 'aSdFam...yCg==' + slug: + type: string + example: 'aSdFam...yCg==' + CatalogLink: + properties: + url: + type: string + example: 'http://some-link.com' + name: + type: string + example: 'whatever name' + CatalogItem: + properties: + id: + type: string + example: 'aSdFam...yCg==' + slug: + type: string + description: > + Composite slug computed from the normalised Bitbucket project key and the repository slug of the item, + in the format `{project-key}_{repo-name}`. Calculated at mapping time; not retrieved from Bitbucket. + example: 'myproject_my-component-repo' + path: + type: string + example: projects/SOMEPROJECT/repos/some-repo/raw/CatalogItem.yaml?at=refs/heads/master + title: + type: string + example: An item title + shortDescription: + type: string + example: This is a short description for the item + descriptionFileId: + type: string + example: cHJvam...0ZXIK + imageFileId: + type: string + example: cHJvam...YXN0Z + itemSrc: + type: string + example: 'https://bitbucket.some-company.com/projects/SOMEPROJECT/repos/some-repo/browse/CatalogItem.yaml?at=refs/heads/master' + tags: + type: array + items: + $ref: '#/components/schemas/CatalogItemTag' + authors: + type: array + items: + type: string + example: + - '@SomeAuthor' + - '@SomeOtherAuthor' + date: + type: string + format: date-time + example: '2021-07-01T00:00:00Z' + userActions: + type: array + items: + $ref: '#/components/schemas/CatalogItemUserAction' + restrictions: + $ref: '#/components/schemas/CatalogItemRestriction' + required: + - id + - title + - shortDescription + - description + - image + - authors + - date + example: + id: aSdFam...yCg== + slug: myproject_some-repo + path: projects/SOMEPROJECT/repos/some-repo/raw/CatalogItem.yaml?at=refs/heads/master + title: An item title + shortDescription: This is a short description for the item + descriptionFileId: cHJvam...0ZXIK + imageFileId: cHJvam...YXN0Z + itemSrc: https://bitbucket.some-company.com/projects/SOMEPROJECT/repos/some-repo/browse/CatalogItem.yaml?at=refs/heads/master + tags: + - label: data + options: + - 'some-data-option' + - 'some-other-data-option' + authors: + - '@SomeAuthor' + - '@SomeOtherAuthor' + date: "2021-07-01T00:00:00Z" + CatalogItemUserAction: + properties: + id: + type: string + nullable: false + example: 'CODE' + displayName: + type: string + nullable: false + example: 'View Code' + url: + type: string + nullable: true + example: 'https://quickstarter' + triggerMessage: + type: string + nullable: true + example: 'Provisioning a component' + requestable: + type: boolean + nullable: true + example: true + restrictionMessage: + type: string + nullable: true + example: 'You do not have permissions to provision this component.' + parameters: + type: array + items: + $ref: '#/components/schemas/CatalogItemUserActionParameter' + example: + id: "PROVISION" + triggerMessage: "Provisioning a component custom message" + parameters: + - name: "workflow" + type: "string" + required: true + defaultValue: "9987" + description: "Workflow to execute." + visible: false + CatalogItemUserActionMessageDefinition: + properties: + id: + type: string + nullable: false + example: 'OPENSHIFT_CONNECTION_ERROR' + type: + $ref: '#/components/schemas/CatalogItemUserActionMessageType' + title: + type: string + nullable: false + example: 'An error occurred while connecting to OpenShift' + message: + type: string + nullable: false + example: > + Authorization error: please check your user credentials for deployment + and try again later. + createsIncident: + type: boolean + nullable: false + example: + id: "OPENSHIFT_CONNECTION_ERROR" + title: "An error occurred while connecting to OpenShift" + message: > + Authorization error: please check your user credentials for deployment + and try again later. + CatalogItemUserActionMessageType: + type: string + enum: + - success + - error + example: error + CatalogItemUserActionParameter: + properties: + name: + type: string + nullable: false + example: 'workflow' + type: + type: string + nullable: false + example: 'string' + required: + type: boolean + example: 'true' + defaultValue: + type: string + nullable: true + example: '123' + defaultValues: + nullable: true + type: array + items: + type: string + example: + - 'some-data-option' + - 'some-other-data-option' + options: + nullable: true + type: array + items: + type: string + example: + - 'some-data-option' + - 'some-other-data-option' + locations: + type: array + nullable: true + items: + $ref: '#/components/schemas/CatalogItemUserActionParameterLocation' + label: + type: string + nullable: false + example: 'Workflow to execute.' + placeholder: + type: string + nullable: true + example: 'some placeholder for a workflow' + hint: + type: string + nullable: true + example: 'some hint for a workflow' + sendOnDeletion: + type: boolean + nullable: true + example: false + visible: + type: boolean + example: 'true' + validations: + type: array + nullable: true + items: + $ref: '#/components/schemas/CatalogItemUserActionParameterValidation' + example: + name: "workflow" + type: "string" + required: true + defaultValue: "9987" + description: "Workflow to execute." + visible: false + CatalogItemUserActionParameterValidation: + properties: + regex: + type: string + example: '/^[a-z\s]{0,255}$/i' + errorMessage: + type: string + example: 'There is an error in the provided value, please check it and try again.' + CatalogItemUserActionParameterLocation: + properties: + location: + type: string + example: 'EU' + value: + type: string + example: '1234' + CatalogItemTag: + properties: + label: + type: string + example: data + options: + type: array + uniqueItems: true + items: + type: string + example: + - 'some-data-option' + - 'some-other-data-option' + CatalogItemFilter: + properties: + label: + type: string + example: business + options: + type: array + uniqueItems: true + items: + type: string + example: + - 'some-business-option' + - 'some-other-business-option' + example: + label: business + options: + - 'some-business-option' + - 'some-other-business-option' + CatalogItemRestriction: + properties: + projects: + type: array + uniqueItems: true + items: + type: string + example: + - 'project-key-1' + - 'project-key-2' + SortOrder: + type: string + enum: + - asc + - desc + example: asc + FileFormat: + type: string + enum: + - image + - markdown + - yaml + example: markdown + RestErrorMessage: + properties: + message: + type: string + required: + - message + ValidationResult: + type: object + properties: + valid: + type: boolean + errors: + type: array + items: + $ref: '#/components/schemas/ValidationMessage' + ValidationMessage: + type: object + properties: + type: + type: string + code: + type: string + message: + type: string + required: + - message + + # === New, explicit request models to avoid sharing === + ProvisioningStatusUpdateRequest: + type: object + properties: + componentId: + type: string + minLength: 1 # disallows empty string "" + pattern: '^(?!\s*$).+' # reject whitespace-only + description: The componentId set by the user. + example: "any-component-id-from-backend" + + catalogItemId: + type: string + description: The base64 encoded path for the catalogItem. It may include branch reference. + example: "cHJvamVjdHMvQ0FURVNUL3JlcG9zL3VzZXItYWN0aW9ucy1pdGVtL3Jhdy9DYXRhbG9nSXRlbS55YW1sP2F0PXJlZnMvaGVhZHMvbWFzdGVy" + + componentUrl: + type: string + description: the repository url where the component was provisioned + example: "https://bitbucket.com/projects/DEVSTACK/repos/devstack-component-catalog" + nullable: true + + parameters: + type: array + description: List of name/value string parameters. + items: + type: object + required: + - name + - value + properties: + name: + type: string + description: Parameter name + example: "environment" + values: + type: array + description: Parameter values + items: + type: string + example: + - "production" + - "staging" + + ProvisioningDeleteRequest: + type: object + properties: + componentId: + type: string + minLength: 1 # disallows empty string "" + pattern: '^(?!\s*$).+' # reject whitespace-only + description: The componentId set by the user. + example: "any-component-id-from-backend" + + parameters: + type: array + description: List of name/value string parameters. + items: + type: object + required: + - name + - value + properties: + name: + type: string + description: Parameter name + example: "environment" + values: + type: array + description: Parameter values + items: + type: string + example: + - "production" + - "staging" \ No newline at end of file diff --git a/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml b/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml new file mode 100644 index 0000000..b2d079c --- /dev/null +++ b/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml @@ -0,0 +1,415 @@ +openapi: 3.0.3 +info: + title: Component Provisioner REST API + version: '1.0.0' + description: > + The Component Provisioner API allows clients to trigger Ansible Automation Platform (AWX) workflows. + + **NOTES**: + - The OpenAPI specification file is also used to [generate](https://openapi-generator.tech/) REST client(s) and a server REST API. + - Clients and servers generated from the same OpenAPI specification version are guaranteed to be **compatible**. + contact: + name: EDPCore Team + url: https://confluence.biscrum.com/pages/viewpage.action?spaceKey=EDP&title=Welcome +servers: + - url: http://{baseurl}/v1 + variables: + baseurl: + default: localhost:8080 + description: Default address for a Component Provisioner's backend REST API instance. +security: + - bearerAuth: [] +tags: + - name: ProvisionerActions + description: ProvisionerAction operations. + - name: ProvisionerMessagesDefinitions + description: Provisioner standardized messages definitions + - name: ProvisionResults + description: Work with project components statuses +paths: + /provision-actions: + post: + tags: + - ProvisionerActions + summary: Execute a provisioning action with parameters + description: > + This endpoint receives ProvisionerActions from clients and triggers them in AWX. + operationId: triggerProvisionAction + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisionAction' + responses: + "201": + description: Provisioning created. + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisionActionResponse' + "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: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + + /catalog-items/{catalogItemId}/user-actions/{action}/message-definitions/{id}: + post: + tags: + - ProvisionerMessagesDefinitions + summary: Get a message definition by catalogItemId and id. + description: > + Returns an standard message definition + operationId: getMessageDefinitionByCatalogItemIdAndMessageId + parameters: + - name: catalogItemId + in: path + description: id for the Catalog Item where Message is defined. + required: true + schema: + type: string + - name: action + in: path + description: Action for the MessageDefinition. + required: true + schema: + type: string + - name: id + in: path + description: id for the MessageDefinition. + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: + type: string + responses: + "200": + description: A single message definition. + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisionerMessageDefinition' + "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' + "404": + description: Catalog not found + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + + /provision/{projectKey}/{status}: + put: + tags: + - ProvisionResults + summary: Notify provisioning Status Update + description: > + This endpoint receives provisioning status update notifications from AWX. + operationId: notifyProvisioningStatusUpdate + parameters: + - name: projectKey + in: path + description: Project key of the provisioned component. + required: true + schema: + type: string + - name: status + in: path + description: Project key of the provisioned component. + required: true + example: CREATING + schema: + type: string + enum: [ CREATING, CREATED, FAILED, DELETING, UNKNOWN ] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + componentId: + type: string + description: The componentId set by the user. + example: "any-component-id-from-backend" + catalogItemId: + type: string + description: The base64 encoded path for the catalogItem. Mind that it may include branch reference. + example: "cHJvamVjdHMvQ0FURVNUL3JlcG9zL3VzZXItYWN0aW9ucy1pdGVtL3Jhdy9DYXRhbG9nSXRlbS55YW1sP2F0PXJlZnMvaGVhZHMvbWFzdGVy" + catalogItemSlug: + type: string + description: The slug for the provisioned component. + example: "myproject_repo_name" + componentUrl: + type: string + description: The bitbucket repository url for the provisioned component. + example: "https://bitbucket.com/projects/myproject/repos/repo_name" + responses: + "200": + description: Provisioning completion notified. + "400": + description: Bad request. + "401": + description: Invalid client token on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. + + /provision/{projectKey}: + delete: + security: + - basicAuth: [ ] # Enable ONLY basicAuth + tags: + - ProvisionResults + summary: Delete provision status component from the file + description: > + This endpoint receives provisioning status delete notifications from Component Provisioner. + operationId: deleteProvisioningStatus + parameters: + - name: projectKey + in: path + description: Project key of the provisioned component. + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisioningDeleteRequest' + responses: + "200": + description: Project component properly deleted. + "400": + description: Bad request. + "401": + description: Invalid credentials on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. + + /support/delete/{projectKey}/{componentId}: + post: + tags: + - ProvisionResults + summary: Request App Support to do operations to delete provision status component (and dependencies) from the file + description: > + This endpoint receives project key and componentId and send an create an incident to app support. + operationId: createIncident + parameters: + - name: projectKey + in: path + description: Project key of the provisioned component. + required: true + schema: + type: string + - name: componentId + in: path + description: Component id of the provisioned component. + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateIncidentAction' + responses: + "201": + description: Incident properly created. + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisionActionResponse' + "400": + description: Bad request. + "401": + description: Invalid credentials on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. +components: + securitySchemes: + bearerAuth: + type: http + description: > + Authorization via bearer token. + scheme: bearer + bearerFormat: JWT + basicAuth: + type: http + scheme: basic + description: Basic authentication for internal provisioning endpoint + schemas: + ProvisionAction: + properties: + id: + type: string + nullable: false + example: 'PROVISION' + parameters: + type: array + items: + $ref: '#/components/schemas/ProvisionActionParameter' + example: + id: "PROVISION" + triggerMessage: "Provisioning a component custom message" + parameters: + - name: "workflow" + type: "string" + required: true + defaultValue: "2558" + description: "Workflow to execute." + visible: false + CreateIncidentAction: + properties: + parameters: + type: array + items: + $ref: '#/components/schemas/CreateIncidentParameter' + example: + parameters: + - name: "cluster_location" + type: "string" + value: "eu" + ProvisionActionParameter: + properties: + name: + type: string + nullable: false + example: 'workflow' + type: + type: string + nullable: false + example: 'string' + value: + type: object + nullable: false + example: '2558' + example: + name: "workflow" + type: "string" + value: "2558" + ProvisionActionResponse: + properties: + failed: + title: Job failed to execute + type: boolean + id: + title: Job ID + type: integer + created: + title: Job created timestamp + type: string + format: date-time + modified: + title: Job modified timestamp + type: string + format: date-time + ProvisionerMessageDefinition: + properties: + id: + type: string + nullable: false + example: 'OPENSHIFT_CONNECTION_ERROR' + type: + $ref: '#/components/schemas/ProvisionerMessageDefinitionType' + title: + type: string + nullable: false + example: 'An error occurred while connecting to OpenShift' + message: + type: string + nullable: false + example: > + Authorization error: please check your user credentials for deployment + and try again later. + createsIncident: + type: boolean + nullable: false + example: + id: "OPENSHIFT_CONNECTION_ERROR" + title: "An error occurred while connecting to OpenShift" + message: > + Authorization error: please check your user credentials for deployment + and try again later. + ProvisionerMessageDefinitionType: + type: string + enum: + - success + - error + example: error + RestErrorMessage: + properties: + message: + type: string + required: + - message + + ProvisioningDeleteRequest: + type: object + properties: + componentId: + type: string + description: The componentId set by the user. + example: "any-component-id-from-backend" + + CreateIncidentParameter: + properties: + name: + type: string + nullable: false + example: 'workflow' + type: + type: string + nullable: false + example: 'string' + value: + type: object + nullable: false + example: '2558' + example: + name: "workflow" + type: "string" + value: "2558" \ No newline at end of file diff --git a/external-service-marketplace/pom.xml b/external-service-marketplace/pom.xml index 6358551..d5134ea 100644 --- a/external-service-marketplace/pom.xml +++ b/external-service-marketplace/pom.xml @@ -87,15 +87,137 @@ jakarta.annotation-api + + jakarta.validation + jakarta.validation-api + + + + io.swagger.core.v3 + swagger-annotations + 2.2.21 + + + + com.google.code.findbugs + jsr305 + 3.0.2 + provided + + + + org.apache.httpcomponents + httpclient + 4.5.14 + provided + + + org.springframework.boot spring-boot-starter-cache + + javax.annotation + javax.annotation-api + 1.3.2 + compile + + + org.jetbrains + annotations + 17.0.0 + compile + + + com.google.code.findbugs + jsr305 + 3.0.2 + compile + + + org.openapitools + openapi-generator-maven-plugin + + + generate-marketplace-catalog-client + + generate + + + FILTER=operationId:getProjectComponents + java + ${project.basedir} + resttemplate + ${project.basedir}/openapi/openapi-component_catalog-v1.0.0.yaml + org.opendevstack.apiservice.externalservice.marketplace.openapi.api + org.opendevstack.apiservice.externalservice.marketplace.openapi.model + org.opendevstack.apiservice.externalservice.marketplace.openapi + false + true + true + true + false + false + false + false + + false + true + true + true + true + + + java8 + jackson + true + + + + + generate-marketplace-provisioner-client + + generate + + + FILTER=operationId:triggerProvisionAction|notifyProvisioningStatusUpdate|createIncident + java + ${project.basedir} + resttemplate + ${project.basedir}/openapi/openapi-component_provisioner-v1.0.0.yaml + org.opendevstack.apiservice.externalservice.marketplace.openapi.api + org.opendevstack.apiservice.externalservice.marketplace.openapi.model + org.opendevstack.apiservice.externalservice.marketplace.openapi + false + true + true + true + false + false + false + false + + false + true + true + true + true + + + java8 + jackson + true + + + + + diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java new file mode 100644 index 0000000..9fd4642 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java @@ -0,0 +1,73 @@ +package org.opendevstack.apiservice.externalservice.marketplace.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.openapitools.jackson.nullable.JsonNullableModule; +import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceInstanceConfig; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.ApiClient; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.auth.HttpBearerAuth; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.web.client.RestTemplate; + +@Getter +@Slf4j +public class MarketplaceApiClient { + + + private final String instanceName; + private final MarketplaceInstanceConfig config; + private final ApiClient apiClient; + + /** + * Constructor for MarketplaceApiClient. + * + * @param instanceName Name of the Marketplace instance + * @param config Configuration for this instance + * @param restTemplate RestTemplate configured with appropriate timeouts and SSL settings + */ + public MarketplaceApiClient(String instanceName, MarketplaceInstanceConfig config, RestTemplate restTemplate) { + this.instanceName = instanceName; + this.config = config; + + // Configure ObjectMapper with JsonNullableModule for the RestTemplate + configureRestTemplateWithJsonNullable(restTemplate); + + // Initialize the generated ApiClient + this.apiClient = new ApiClient(restTemplate); + + // Configure authentication – prefer bearer token over basic auth + if (config.getBearerToken() != null && !config.getBearerToken().isEmpty()) { + HttpBearerAuth auth = (HttpBearerAuth) this.apiClient.getAuthentication("bearerAuth"); + auth.setBearerToken(config.getBearerToken()); + log.info("MarketplaceApiClient initialized for instance '{}' with bearer token authentication", + instanceName); + } else if (config.getUsername() != null && config.getPassword() != null) { + this.apiClient.setUsername(config.getUsername()); + this.apiClient.setPassword(config.getPassword()); + log.info("MarketplaceApiClient initialized for instance '{}' with basic authentication", + instanceName); + } else { + log.warn("MarketplaceApiClient initialized for instance '{}' without authentication " + + "(neither bearer token nor username/password provided)", instanceName); + } + } + + /** + * Configure RestTemplate's ObjectMapper to handle JsonNullable types. + * + * @param restTemplate RestTemplate to configure + */ + private void configureRestTemplateWithJsonNullable(RestTemplate restTemplate) { + for (HttpMessageConverter converter : restTemplate.getMessageConverters()) { + if (converter instanceof MappingJackson2HttpMessageConverter jacksonConverter) { + ObjectMapper objectMapper = jacksonConverter.getObjectMapper(); + objectMapper.registerModule(new JsonNullableModule()); + log.debug("Registered JsonNullableModule with ObjectMapper for instance '{}'", instanceName); + return; + } + } + log.warn("No MappingJackson2HttpMessageConverter found in RestTemplate for instance '{}'", instanceName); + } +} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactory.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactory.java new file mode 100644 index 0000000..70cc2d0 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactory.java @@ -0,0 +1,198 @@ +package org.opendevstack.apiservice.externalservice.marketplace.client; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceInstanceConfig; +import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceServiceConfig; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.X509Certificate; +import java.util.Map; +import java.util.Set; + +@Component +@Slf4j +public class MarketplaceApiClientFactory { + + private final MarketplaceServiceConfig configuration; + private final RestTemplateBuilder restTemplateBuilder; + + /** + * Constructor with dependency injection. + * + * @param configuration Marketplace service configuration + * @param restTemplateBuilder RestTemplate builder for creating HTTP clients + */ + public MarketplaceApiClientFactory(MarketplaceServiceConfig configuration, + RestTemplateBuilder restTemplateBuilder) { + this.configuration = configuration; + this.restTemplateBuilder = restTemplateBuilder; + + log.info("MarketplaceApiClientFactory initialized with {} instance(s)", + configuration.getInstances().size()); + } + + /** + * Resolve the effective instance name. + *
    + *
  • If the default instance is configured via {@code externalservices.marketplace.default-instance}, it is returned.
  • + *
  • Otherwise the first entry of the instances map is returned (insertion order).
  • + *
  • If no instances are configured at all, a {@link MarketplaceException} is thrown.
  • + *
+ * + * @return The resolved instance name (never {@code null}/blank) + * @throws MarketplaceException if no Marketplace instances are configured + */ + public String getDefaultInstanceName() throws MarketplaceException { + + String defaultInstance = configuration.getDefaultInstance(); + if (defaultInstance != null && !defaultInstance.isBlank()) { + return defaultInstance; + } + + Map instances = configuration.getInstances(); + if (instances == null || instances.isEmpty()) { + throw new MarketplaceException("No Marketplace instances configured"); + } + + return instances.keySet().iterator().next(); + } + + /** + * Get a {@link MarketplaceApiClient} for a specific instance. + * If {@code instanceName} is {@code null} or blank, the default instance is used. + * + * @param instanceName Name of the Marketplace instance, or {@code null}/{@code ""} for the default + * @return Configured MarketplaceApiClient + * @throws MarketplaceException if the instance is not configured + */ + @Cacheable(value = "marketplaceApiClients", key = "#instanceName", condition = "#instanceName != null && !#instanceName.isBlank()") + public MarketplaceApiClient getClient(String instanceName) throws MarketplaceException { + if (instanceName == null || instanceName.isBlank()) { + throw new MarketplaceException( + String.format("Provide instance name. Available instances: %s", + configuration.getInstances().keySet())); + } + + MarketplaceInstanceConfig instanceConfig = configuration.getInstances().get(instanceName); + + if (instanceConfig == null) { + throw new MarketplaceException( + String.format("Marketplace instance '%s' is not configured. Available instances: %s", + instanceName, configuration.getInstances().keySet())); + } + + log.info("Creating new MarketplaceApiClient for instance '{}'", instanceName); + + RestTemplate restTemplate = createRestTemplate(instanceConfig); + return new MarketplaceApiClient(instanceName, instanceConfig, restTemplate); + } + + /** + * Get the default client, as determined by {@code externalservices.marketplace.default-instance}. + * Falls back to the first configured instance when {@code default-instance} is not set. + * + * @return MarketplaceApiClient for the default instance + * @throws MarketplaceException if no instances are configured + */ + @Cacheable(value = "marketplaceApiClients", key = "'default'") + public MarketplaceApiClient getClient() throws MarketplaceException { + String defaultInstanceName = getDefaultInstanceName(); + MarketplaceInstanceConfig instanceConfig = configuration.getInstances().get(defaultInstanceName); + RestTemplate restTemplate = createRestTemplate(instanceConfig); + + return new MarketplaceApiClient(defaultInstanceName, instanceConfig, restTemplate); + } + + /** + * Get all available instance names. + * + * @return Set of configured instance names + */ + public Set getAvailableInstances() { + return configuration.getInstances().keySet(); + } + + /** + * Check if an instance is configured. + * + * @param instanceName Name of the instance to check + * @return true if configured, false otherwise + */ + public boolean hasInstance(String instanceName) { + return configuration.getInstances().containsKey(instanceName); + } + + /** + * Clear the client cache (useful for testing or when configuration changes). + */ + @CacheEvict(value = "marketplaceApiClients", allEntries = true) + public void clearCache() { + log.info("Clearing MarketplaceApiClient cache"); + } + + /** + * Create a configured RestTemplate for a Marketplace instance. + * + * @param config Configuration for the instance + * @return Configured RestTemplate + */ + private RestTemplate createRestTemplate(MarketplaceInstanceConfig config) { + RestTemplate restTemplate = restTemplateBuilder.build(); + + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(config.getConnectionTimeout()); + requestFactory.setReadTimeout(config.getReadTimeout()); + restTemplate.setRequestFactory(requestFactory); + + if (config.isTrustAllCertificates()) { + log.warn("Trust all certificates is enabled for Marketplace API connection. " + + "This should only be used in development environments!"); + configureTrustAllCertificates(); + } + + return restTemplate; + } + + /** + * Configure RestTemplate to trust all SSL certificates. + * WARNING: This should only be used in development environments. + */ + @SuppressWarnings({"java:S4830", "java:S1186"}) + private void configureTrustAllCertificates() { + try { + TrustManager[] trustAllCerttificates = new TrustManager[]{ + new X509TrustManager() { + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + public void checkClientTrusted(X509Certificate[] certs, String authType) { + } + // Intentionally empty - trusting all certificates for development environments + public void checkServerTrusted(X509Certificate[] certs, String authType) { + } + } + }; + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, trustAllCerttificates, new java.security.SecureRandom()); + + HttpsURLConnection.setDefaultSSLSocketFactory(context.getSocketFactory()); + // Intentionally disabling hostname verification for development environments + HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true); + + } catch (NoSuchAlgorithmException | KeyManagementException ex) { + log.error("Failed to configure SSL trust all certificates for Marketplace API", ex); + } + } +} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java new file mode 100644 index 0000000..0414f20 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java @@ -0,0 +1,62 @@ +package org.opendevstack.apiservice.externalservice.marketplace.config; + +import lombok.Data; + +@Data +public class MarketplaceInstanceConfig { + /** + * The project components base URL of the Marketplace + */ + private String projectComponentsBaseUrl; + + /** + * The provisioner actions base URL of the Marketplace + */ + private String provisionerActionsBaseUrl; + + /** + * Authentication access token for accessing the Marketplace API + */ + private String accessToken; + + /** + * Authentication bearer token for accessing the Marketplace API + */ + private String bearerToken; + + /** + * Username for authentication (used with password for basic auth). + * Only used if bearerToken is not provided. + */ + private String username; + + /** + * Password or personal access token for authentication. + * Only used if bearerToken is not provided. + */ + private String password; + + /** + * Connection timeout in milliseconds (default: 30000) + */ + private int connectionTimeout = 30000; + + /** + * Read timeout in milliseconds (default: 30000). + */ + private int readTimeout = 30000; + + /** + * Whether to trust all SSL certificates (default: false). + * WARNING: Should only be used in development environments. + */ + private boolean trustAllCertificates = false; + + private String workflow; + + private String odsNamespace; + + private String quickstarterRepository; + + private String catalogItemId; +} \ No newline at end of file diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceServiceConfig.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceServiceConfig.java new file mode 100644 index 0000000..bbaec73 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceServiceConfig.java @@ -0,0 +1,25 @@ +package org.opendevstack.apiservice.externalservice.marketplace.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +@ConfigurationProperties(prefix = "externalservices.marketplace") +@Data +public class MarketplaceServiceConfig { + + /** + * Name of the default Marketplace instance to use when no instance name is provided. + * If not set, the first configured instance is used as default. + */ + private String defaultInstance; + + /** + * Map of Marketplace instances with the instance name as the key and the configuration as the value. + */ + private Map instances = new HashMap<>(); +} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/exception/MarketplaceException.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/exception/MarketplaceException.java new file mode 100644 index 0000000..f31934c --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/exception/MarketplaceException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.externalservice.marketplace.exception; + +public class MarketplaceException extends Exception { + + public MarketplaceException(String message) { + super(message); + } + + public MarketplaceException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/CreateComponentParameter.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/CreateComponentParameter.java deleted file mode 100644 index a4409c3..0000000 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/CreateComponentParameter.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.opendevstack.apiservice.externalservice.marketplace.model; - - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@NoArgsConstructor -@AllArgsConstructor -@Data -public class CreateComponentParameter { - - private String name; - private String type; - private String value; - -} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java index 8873f42..28f74f8 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java @@ -1,15 +1,35 @@ package org.opendevstack.apiservice.externalservice.marketplace.service; import org.opendevstack.apiservice.externalservice.api.ExternalService; -import org.opendevstack.apiservice.externalservice.marketplace.model.CreateComponentParameter; -import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentInfo; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionParameter; import java.util.List; +import java.util.Set; public interface MarketplaceService extends ExternalService { + Set getAvailableInstances(); - ProjectComponent getProjectComponent(String projectId, String componentId); + boolean hasInstance(String instanceName); + + String getDefaultInstance() throws MarketplaceException; + + ProjectComponentInfo getProjectComponent(String projectId, String componentId) throws MarketplaceException; + + ProjectComponentInfo getProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException; + + boolean provisionProjectComponent(String projectId, List params) throws MarketplaceException; + + boolean provisionProjectComponent(String instanceName, String projectId, List params) throws MarketplaceException; + + boolean deleteProjectComponent(String projectId, String componentId) throws MarketplaceException; + + boolean deleteProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException; + + void registerProjectComponent(String projectId, String componentId) throws MarketplaceException; + + void registerProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException; - ProjectComponent createProjectComponent(String projectId, List params); } diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java new file mode 100644 index 0000000..67767d6 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java @@ -0,0 +1,217 @@ +package org.opendevstack.apiservice.externalservice.marketplace.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClient; +import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClientFactory; +import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceInstanceConfig; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.ApiClient; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.api.ProjectComponentsApi; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.api.ProvisionResultsApi; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.api.ProvisionerActionsApi; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CreateIncidentAction; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.NotifyProvisioningStatusUpdateRequest; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentInfo; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionAction; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionResponse; +import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClientException; + +import java.util.List; +import java.util.Set; + +@Service +@Slf4j +public class MarketplaceServiceImpl implements MarketplaceService { + + private final MarketplaceApiClientFactory clientFactory; + + public MarketplaceServiceImpl(MarketplaceApiClientFactory clientFactory) { + this.clientFactory = clientFactory; + log.info("MarketplaceServiceImpl initialized"); + } + + @Override + public ProjectComponentInfo getProjectComponent(String projectId, String componentId) throws MarketplaceException { + return getProjectComponent(getDefaultInstance(), projectId, componentId); + } + + @Override + public ProjectComponentInfo getProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException { + log.debug("Marketplace service GET component with id {} for project {} in instance {} ", componentId, projectId, instanceName); + try { + MarketplaceApiClient marketplaceClient = clientFactory.getClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + ProjectComponentsApi projectComponentsApi = new ProjectComponentsApi(apiClient); + apiClient.setBasePath(marketplaceClient.getConfig().getProjectComponentsBaseUrl()); + List components = projectComponentsApi.getProjectComponents(projectId); + if (components == null || components.isEmpty()) { + return null; + } + return components.stream() + .filter(component -> component.getComponentId().equals(componentId)) + .findFirst() + .orElse(null); + } catch (HttpClientErrorException.NotFound e) { + log.debug("Component with id '{}' not found in Marketplace instance '{}' for project '{}'", + componentId, instanceName, projectId); + return null; + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new MarketplaceException( + String.format("Access denied when getting project component '%s' in project '%s' and instance '%s'", + componentId, projectId, instanceName), e); + } catch (RestClientException e) { + throw new MarketplaceException( + String.format("Failed to retrieve project component '%s' in project '%s' and instance '%s'", + componentId, projectId, instanceName), e); + } + } + + @Override + public boolean provisionProjectComponent(String projectId, List params) throws MarketplaceException { + return provisionProjectComponent(getDefaultInstance(), projectId, params); + } + + @Override + public boolean provisionProjectComponent(String instanceName, String projectId, List params) throws MarketplaceException { + log.debug("Marketplace service PROVISION component for project {}: ", projectId); + try { + MarketplaceApiClient marketplaceClient = clientFactory.getClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + MarketplaceInstanceConfig config = marketplaceClient.getConfig(); + + String provisionerActionsBaseUrl = config.getProvisionerActionsBaseUrl(); + String accessToken = config.getAccessToken(); + String workflow = config.getWorkflow(); + String odsNamespace = config.getOdsNamespace(); + String quickstarterRepository = config.getQuickstarterRepository(); + String catalogItemId = config.getCatalogItemId(); + + ProvisionAction provisionAction = new ProvisionAction(); + provisionAction.setId("PROVISION"); + provisionAction.addParametersItem(new ProvisionActionParameter().name("project_key").type("string").value(projectId)); + + //TODO: currently in dev is not working but, we need to add + //TODO: the error 500 on POST request for "https://component-provisioner-devstack-dev.apps.eu-dev.ocp.aws.boehringer.com/v1/provision-actions": "{"message":"Duplicate key access_token (attempted merging values [...]])"}" +// provisionAction.addParametersItem(new ProvisionActionParameter().name("access_token").type("string").value(accessToken)); + provisionAction.addParametersItem(new ProvisionActionParameter().name("workflow").type("string").value(workflow)); + provisionAction.addParametersItem(new ProvisionActionParameter().name("ods_namespace").type("string").value(odsNamespace)); + provisionAction.addParametersItem(new ProvisionActionParameter().name("quickstarter_repo").type("string").value(quickstarterRepository)); + provisionAction.addParametersItem(new ProvisionActionParameter().name("catalog_item_id").type("string").value(catalogItemId)); + + params.forEach(provisionAction::addParametersItem); + + ProvisionerActionsApi provisionerActionsApi = new ProvisionerActionsApi(apiClient); + apiClient.setBasePath(provisionerActionsBaseUrl); + + ProvisionActionResponse response = provisionerActionsApi.triggerProvisionAction(provisionAction); + return !response.getFailed(); + } catch (HttpClientErrorException.Conflict e) { + throw new MarketplaceException("This component name already exists, please choose another name.", e); + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new MarketplaceException( + String.format("Access denied when provisioning project component in project '%s' and instance '%s'", + projectId, instanceName), e); + } catch (RestClientException e) { + throw new MarketplaceException( + String.format("Failed to provision project component in project '%s' and instance '%s'", + projectId, instanceName), e); + } + } + + @Override + public boolean deleteProjectComponent(String projectId, String componentId) throws MarketplaceException { + return deleteProjectComponent(getDefaultInstance(), projectId, componentId); + } + + @Override + public boolean deleteProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException { + log.debug("Marketplace service DELETE component {} for project {}: ", componentId, projectId); + try { + MarketplaceApiClient marketplaceClient = clientFactory.getClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + + ProvisionResultsApi provisionResultsApi = new ProvisionResultsApi(apiClient); + apiClient.setBasePath(marketplaceClient.getConfig().getProvisionerActionsBaseUrl()); + log.debug("Api client base path: {}", apiClient.getBasePath()); + + CreateIncidentAction deleteAction = new CreateIncidentAction(); + ProvisionActionResponse response = provisionResultsApi.createIncident(projectId, componentId, deleteAction); + return !response.getFailed(); + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new MarketplaceException( + String.format("Access denied when deleting project component '%s' in project '%s' and instance '%s'", + componentId, projectId, instanceName), e); + } catch (RestClientException e) { + throw new MarketplaceException( + String.format("Failed to delete project component '%s' in project '%s' and instance '%s'", + componentId, projectId, instanceName), e); + } + } + + @Override + public void registerProjectComponent(String projectId, String componentId) throws MarketplaceException { + registerProjectComponent(getDefaultInstance(), projectId, componentId); + } + + @Override + public void registerProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException { + log.debug("Marketplace service REGISTER component {} for project {}: ", componentId, projectId); + try { + MarketplaceApiClient marketplaceClient = clientFactory.getClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + + ProvisionResultsApi provisionResultsApi = new ProvisionResultsApi(apiClient); + apiClient.setBasePath(marketplaceClient.getConfig().getProvisionerActionsBaseUrl()); + log.debug("Api client base path: {}", apiClient.getBasePath()); + + NotifyProvisioningStatusUpdateRequest registerRequest = new NotifyProvisioningStatusUpdateRequest(); + registerRequest.setComponentId(componentId); + provisionResultsApi.notifyProvisioningStatusUpdate(projectId, "CREATED", registerRequest); + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new MarketplaceException( + String.format("Access denied when registering project component '%s' in project '%s' and instance '%s'", + componentId, projectId, instanceName), e); + } catch (RestClientException e) { + throw new MarketplaceException( + String.format("Failed to register project component '%s' in project '%s' and instance '%s'", + componentId, projectId, instanceName), e); + } + } + + /** + * {@inheritDoc} + */ + @Override + public String getDefaultInstance() throws MarketplaceException { + return clientFactory.getDefaultInstanceName(); + } + + /** + * {@inheritDoc} + * + * Returns {@code false} (without throwing) if no instances are configured. + */ + @Override + public boolean isHealthy() { + Set instances = getAvailableInstances(); + if (instances.isEmpty()) { + log.warn("No Marketplace instances configured – reporting unhealthy"); + return false; + } + return true; + } + + @Override + public Set getAvailableInstances() { + return clientFactory.getAvailableInstances(); + } + + @Override + public boolean hasInstance(String instanceName) { + return clientFactory.hasInstance(instanceName); + } +} 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 deleted file mode 100644 index ec1007c..0000000 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceMockImpl.java +++ /dev/null @@ -1,84 +0,0 @@ -package org.opendevstack.apiservice.externalservice.marketplace.service.impl; - -import lombok.extern.slf4j.Slf4j; -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.springframework.stereotype.Service; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -@Service -@Slf4j -public class MarketplaceServiceMockImpl implements MarketplaceService { - - @Override - public boolean isHealthy() { - return true; - } - - private Map mockComponentsCache = new HashMap<>(); - - public ProjectComponent getProjectComponent(String projectId, String componentId) { - log.info("Get component with id '" + componentId + "' for project '" + projectId + "'"); - ComposedId composedId = new ComposedId(projectId, componentId); - return mockComponentsCache.get(composedId); - } - - public ProjectComponent createProjectComponent(String projectId, List createComponentParams) { - log.info("Creating component for project '" + projectId + "'" + " with request: " + createComponentParams); - ProjectComponent mockComponent = new ProjectComponent(); - mockComponent.setComponentId(UUID.randomUUID()); - mockComponent.setCanBeDeleted(true); - mockComponent.setStatus("CREATING"); - 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 extractParam(List params, String key) { - return params.stream() - .filter(p -> key.equals(p.getName())) - .map(CreateComponentParameter::getValue) - .findFirst() - .orElse(null); - } - - class ComposedId { - private String projectId; - private String componentId; - - public ComposedId(String projectId, String componentId) { - this.projectId = projectId; - this.componentId = componentId; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - ComposedId that = (ComposedId) o; - - if (!projectId.equals(that.projectId)) return false; - return componentId.equals(that.componentId); - } - - @Override - public int hashCode() { - int result = projectId.hashCode(); - result = 31 * result + componentId.hashCode(); - return result; - } - } -} diff --git a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactoryTest.java b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactoryTest.java new file mode 100644 index 0000000..d8bdbfe --- /dev/null +++ b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactoryTest.java @@ -0,0 +1,163 @@ +package org.opendevstack.apiservice.externalservice.marketplace.client; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceInstanceConfig; +import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceServiceConfig; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.web.client.RestTemplate; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link MarketplaceApiClientFactory}. + * Focuses on the default-instance resolution logic introduced in {@code resolveInstanceName}. + */ +@ExtendWith(MockitoExtension.class) +class MarketplaceApiClientFactoryTest { + + @Mock + private RestTemplateBuilder restTemplateBuilder; + + @Mock + private RestTemplate restTemplate; + + private MarketplaceServiceConfig configuration; + + @BeforeEach + void setUp() { + configuration = new MarketplaceServiceConfig(); + lenient().when(restTemplateBuilder.build()).thenReturn(restTemplate); + } + + private MarketplaceApiClientFactory factory() { + return new MarketplaceApiClientFactory(configuration, restTemplateBuilder); + } + + // ------------------------------------------------------------------------- + // resolveInstanceName → configured default + // ------------------------------------------------------------------------- + + @Test + void resolveInstanceName_null_returnsConfiguredDefaultInstance() throws MarketplaceException { + configuration.setDefaultInstance("prod"); + + assertEquals("prod", factory().getDefaultInstanceName()); + } + + // ------------------------------------------------------------------------- + // resolveInstanceName – without default → fallback to first instance + // ------------------------------------------------------------------------- + + @Test + void resolveInstanceName_null_noDefaultConfigured_returnsFirstInstance() throws MarketplaceException { + // LinkedHashMap preserves insertion order → "alpha" is first + Map instances = new LinkedHashMap<>(); + instances.put("alpha", config("https://marketplace.example.com")); + instances.put("beta", config("https://marketplace-beta.example.com")); + configuration.setInstances(instances); + + assertEquals("alpha", factory().getDefaultInstanceName()); + } + + // ------------------------------------------------------------------------- + // resolveInstanceName – no instances at all → exception + // ------------------------------------------------------------------------- + + @Test + void resolveInstanceName_null_noInstancesConfigured_throwsMarketplaceException() { + // no instances set → empty map + MarketplaceApiClientFactory f = factory(); + + MarketplaceException ex = assertThrows(MarketplaceException.class, () -> f.getDefaultInstanceName()); + assertTrue(ex.getMessage().toLowerCase().contains("no marketplace instances configured"), + "Expected 'no marketplace instances configured' in: " + ex.getMessage()); + } + + @Test + void getClient_null_throwsMarketplaceException() throws MarketplaceException { + MarketplaceException ex = assertThrows(MarketplaceException.class, () -> factory().getClient(null)); + assertTrue(ex.getMessage().toLowerCase().contains("provide instance name"), + "Expected 'provide instance name' in: " + ex.getMessage()); + } + + @Test + void getClient_blank_throwsMarketplaceException() throws MarketplaceException { + MarketplaceException ex = assertThrows(MarketplaceException.class, () -> factory().getClient("")); + assertTrue(ex.getMessage().toLowerCase().contains("provide instance name"), + "Expected 'provide instance name' in: " + ex.getMessage()); + } + + @Test + void getClient_unknownInstance_throwsMarketplaceException() { + configuration.setInstances(Map.of("dev", config("https://marketplace.dev.example.com"))); + + MarketplaceException ex = assertThrows(MarketplaceException.class, + () -> factory().getClient("nonexistent")); + assertTrue(ex.getMessage().contains("not configured")); + assertTrue(ex.getMessage().contains("nonexistent")); + } + + @Test + void getClient_returnsClientForConfiguredDefaultInstance() throws MarketplaceException { + when(restTemplateBuilder.build()).thenReturn(restTemplate); + configuration.setDefaultInstance("prod"); + configuration.setInstances(orderedMap("dev", "prod")); + + MarketplaceApiClient client = factory().getClient(); + + assertNotNull(client); + assertEquals("prod", client.getInstanceName()); + } + + @Test + void getClient_noDefaultConfigured_returnsFirstInstance() throws MarketplaceException { + when(restTemplateBuilder.build()).thenReturn(restTemplate); + Map instances = new LinkedHashMap<>(); + instances.put("alpha", config("https://marketplace.example.com")); + instances.put("beta", config("https://marketplace-beta.example.com")); + configuration.setInstances(instances); + + MarketplaceApiClient client = factory().getClient(); + + assertNotNull(client); + assertEquals("alpha", client.getInstanceName()); + } + + @Test + void getClient_noInstancesConfigured_throwsMarketplaceException() { + MarketplaceException ex = assertThrows(MarketplaceException.class, () -> factory().getClient()); + assertTrue(ex.getMessage().toLowerCase().contains("no marketplace instances configured")); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static MarketplaceInstanceConfig config(String baseUrl) { + MarketplaceInstanceConfig c = new MarketplaceInstanceConfig(); + c.setProjectComponentsBaseUrl(baseUrl); + c.setProvisionerActionsBaseUrl(baseUrl); + return c; + } + + /** Creates a LinkedHashMap with two configs using their names as base-url stems. */ + private static Map orderedMap(String first, String second) { + Map m = new LinkedHashMap<>(); + m.put(first, config("https://" + first + ".example.com")); + m.put(second, config("https://" + second + ".example.com")); + return m; + } +} \ No newline at end of file diff --git a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java new file mode 100644 index 0000000..78b73fd --- /dev/null +++ b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java @@ -0,0 +1,368 @@ +package org.opendevstack.apiservice.externalservice.marketplace.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClient; +import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClientFactory; +import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceInstanceConfig; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.ApiClient; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentInfo; +import org.opendevstack.apiservice.externalservice.marketplace.service.impl.MarketplaceServiceImpl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClientException; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link MarketplaceService}. + * These tests use mocks and do not require actual Marketplace connectivity. + */ +@ExtendWith(MockitoExtension.class) +class MarketplaceServiceImplTest { + + // TODO tests for the rest of the methods in MarketplaceServiceImpl + + @Mock + private MarketplaceApiClientFactory clientFactory; + + @Mock + private MarketplaceApiClient marketplaceApiClient; + + @Mock + private ApiClient apiClient; + + private MarketplaceService marketplaceService; + + @BeforeEach + void setUp() { + marketplaceService = new MarketplaceServiceImpl(clientFactory); + // Stub ApiClient utility methods used by the generated ProjectApi / ServerInfoApi + // before invokeAPI is reached. Without these, putAll(null) causes NullPointerException. + lenient().when(apiClient.parameterToMultiValueMap(any(), anyString(), any())) + .thenReturn(new LinkedMultiValueMap<>()); + lenient().when(apiClient.selectHeaderAccept(any())) + .thenReturn(List.of(MediaType.APPLICATION_JSON)); + lenient().when(apiClient.selectHeaderContentType(any())) + .thenReturn(MediaType.APPLICATION_JSON); + } + + // ------------------------------------------------------------------------- + // getProjectComponent + // ------------------------------------------------------------------------- + + @Test + void testGetProjectComponent_InstanceNotConfigured() throws MarketplaceException { + // Arrange + String instanceName = "nonexistent"; + String projectKey = "PROJ"; + String componentId = "test-component"; + + when(clientFactory.getClient(instanceName)) + .thenThrow(new MarketplaceException("Marketplace instance 'nonexistent' is not configured")); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.getProjectComponent(instanceName, projectKey, componentId)); + + assertTrue(exception.getMessage().contains("not configured")); + verify(clientFactory).getClient(instanceName); + } + + @Test + void testGetProjectComponent_RestClientException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenThrow(new RestClientException("Connection failed")); + + // Act & Assert + assertThrows(MarketplaceException.class, () -> + marketplaceService.getProjectComponent(instanceName, projectKey, componentId)); + + verify(clientFactory).getClient(instanceName); + verify(marketplaceApiClient).getApiClient(); + } + + @Test + void testGetProjectComponent_NotFound_ReturnsNull() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "UNKNOWN"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setAccessToken("1234"); + HttpClientErrorException notFoundEx = HttpClientErrorException.create( + HttpStatus.NOT_FOUND, "Not Found", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenThrow(notFoundEx); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + // Act + ProjectComponentInfo result = marketplaceService.getProjectComponent(instanceName, projectKey, componentId); + + // Assert + assertNull(result); + verify(clientFactory).getClient(instanceName); + } + + // ------------------------------------------------------------------------- + // isHealthy + // ------------------------------------------------------------------------- + + @Test + void testIsHealthy_NoInstancesConfigured_ReturnsFalse() { + // Arrange + when(clientFactory.getAvailableInstances()).thenReturn(Collections.emptySet()); + + // Act + boolean result = marketplaceService.isHealthy(); + + // Assert + assertFalse(result); + } + + // TODO reenable these tests when we implement the health check to actually call the Marketplace API. + // For now, since isHealthy only checks if instances are configured, this test is not relevant + // and fails due to the RestClientException being thrown by the mocked ApiClient when invokeAPI is called. +// @Test +// void testIsHealthy_RestClientException_ReturnsFalse() throws MarketplaceException { +// // Arrange +// when(clientFactory.getAvailableInstances()).thenReturn(Set.of("dev")); +// when(clientFactory.getClient()).thenReturn(marketplaceApiClient); +// when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); +// when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) +// .thenThrow(new RestClientException("Connection refused")); +// +// // Act +// boolean result = marketplaceService.isHealthy(); +// +// // Assert +// assertFalse(result); +// } +// +// @Test +// void testIsHealthy_WhenException_ReturnsFalse() throws MarketplaceException { +// // Arrange +// when(clientFactory.getAvailableInstances()).thenReturn(Set.of("dev")); +// when(clientFactory.getClient()).thenThrow(new MarketplaceException("No Marketplace instances configured")); +// +// // Act +// boolean result = marketplaceService.isHealthy(); +// +// // Assert +// assertFalse(result); +// } + + // ------------------------------------------------------------------------- + // getAvailableInstances / hasInstance + // ------------------------------------------------------------------------- + + @Test + void testGetAvailableInstances() { + // Arrange + Set expected = Set.of("dev", "prod"); + when(clientFactory.getAvailableInstances()).thenReturn(expected); + + // Act + Set result = marketplaceService.getAvailableInstances(); + + // Assert + assertEquals(expected, result); + verify(clientFactory).getAvailableInstances(); + } + + @Test + void testHasInstance_Existing_ReturnsTrue() { + // Arrange + when(clientFactory.hasInstance("dev")).thenReturn(true); + + // Act + Assert + assertTrue(marketplaceService.hasInstance("dev")); + } + + @Test + void testHasInstance_NonExistent_ReturnsFalse() { + // Arrange + when(clientFactory.hasInstance("nope")).thenReturn(false); + + // Act + Assert + assertFalse(marketplaceService.hasInstance("nope")); + } + + // ------------------------------------------------------------------------- + // Default-instance support + // ------------------------------------------------------------------------- + + @Test + void testGetProjectComponent_NoInstanceArg_UsesDefaultClient() throws MarketplaceException { + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setAccessToken("1234"); + + when(clientFactory.getClient(null)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(ResponseEntity.ok(null)); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + ProjectComponentInfo result = marketplaceService.getProjectComponent(projectKey, componentId); + + assertNull(result); + verify(clientFactory).getClient(null); + } + + @Test + void testGetProjectComponent_NullInstanceName_UsesDefaultClient() throws MarketplaceException { + // Passing null explicitly as instanceName should also resolve to the default + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setAccessToken("1234"); + + when(clientFactory.getClient(null)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(ResponseEntity.ok(null)); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + ProjectComponentInfo result = marketplaceService.getProjectComponent(null, projectKey, componentId); + + assertNull(result); + verify(clientFactory).getClient(null); + } + + @Test + void testGetProjectComponent_BlankInstanceName_UsesDefaultClient() throws MarketplaceException { + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setAccessToken("1234"); + + when(clientFactory.getClient("")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(ResponseEntity.ok(null)); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + ProjectComponentInfo result = marketplaceService.getProjectComponent("", projectKey, componentId); + + assertNull(result); + verify(clientFactory).getClient(""); + } + + @Test + void testGetProjectComponent_NoInstanceArg_NotFound_ReturnsFalse() throws MarketplaceException { + String projectKey = "ZZZNOPE"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setAccessToken("1234"); + + HttpClientErrorException notFoundEx = HttpClientErrorException.create( + HttpStatus.NOT_FOUND, "Not Found", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(null)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenThrow(notFoundEx); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + ProjectComponentInfo result = marketplaceService.getProjectComponent(projectKey, componentId); + + assertNull(result); + } + + @Test + void testGetProjectComponent_NoInstanceArg_RestClientException_ThrowsMarketplaceException() throws MarketplaceException { + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setAccessToken("1234"); + + when(clientFactory.getClient(null)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenThrow(new RestClientException("timeout")); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + assertThrows(MarketplaceException.class, () -> marketplaceService.getProjectComponent("PROJ", + "test-component")); + } + + @Test + void testGetDefaultInstance_DelegatesToFactory() throws MarketplaceException { + when(clientFactory.getDefaultInstanceName()).thenReturn("prod"); + + String result = marketplaceService.getDefaultInstance(); + + assertEquals("prod", result); + verify(clientFactory).getDefaultInstanceName(); + } + + @Test + void testGetDefaultInstance_FactoryThrows_PropagatesException() throws MarketplaceException { + when(clientFactory.getDefaultInstanceName()) + .thenThrow(new MarketplaceException("No Marketplace instances configured")); + + assertThrows(MarketplaceException.class, () -> marketplaceService.getDefaultInstance()); + } + + @Test + void testProvisionProjectComponent_Conflict_ThrowsMarketplaceExceptionWithDuplicateMessage() throws MarketplaceException { + String instanceName = "dev"; + String projectKey = "EDPC"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + + HttpClientErrorException conflictEx = HttpClientErrorException.create( + HttpStatus.CONFLICT, + "Conflict", + HttpHeaders.EMPTY, + "{\"message\":\"This component name already exists, please choose another name.\"}".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8 + ); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenThrow(conflictEx); + + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.provisionProjectComponent(instanceName, projectKey, List.of())); + + assertEquals("This component name already exists, please choose another name.", exception.getMessage()); + } + +} \ No newline at end of file