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/Makefile b/Makefile index 79b4438..ff7c92a 100644 --- a/Makefile +++ b/Makefile @@ -91,8 +91,25 @@ check-maven: clean: check-maven @echo "$(BLUE)Cleaning build artifacts...$(NC)" $(MAVEN_WRAPPER) clean + @echo "$(BLUE)Removing all target directories across modules...$(NC)" + @find . -type d -name target -prune -exec rm -rf {} + + @echo "$(BLUE)Removing generated OpenAPI model directories...$(NC)" + @find . -type d -path "*/src/main/java/*/model" \ + ! -path "./external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/model" \ + ! -path "./service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model" \ + ! -path "./external-service-projects-info-service/src/main/java/org/opendevstack/apiservice/externalservice/projectsinfoservice/model" \ + ! -path "./external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model" \ + ! -path "./external-service-uipath/src/main/java/org/opendevstack/apiservice/externalservice/uipath/model" \ + -prune -exec rm -rf {} + @echo "$(GREEN)✓ Clean complete$(NC)" +## Remove local Maven cache for org.opendevstack.apiservice +clean-cache: + @echo "$(BLUE)Removing local Maven cache for org.opendevstack.apiservice...$(NC)" + @rm -rf "$$HOME/.m2/repository/org/opendevstack/apiservice" + @rm -rf "$$HOME/.m2/repositories/org/opendevstack/apiservice" + @echo "$(GREEN)✓ Maven cache clean complete$(NC)" + ## Compile the project compile: check-java check-maven @echo "$(BLUE)Compiling project...$(NC)" 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/pom.xml b/api-project-component-v0/pom.xml index 845c6d5..35f3ddf 100644 --- a/api-project-component-v0/pom.xml +++ b/api-project-component-v0/pom.xml @@ -50,6 +50,12 @@ ${project.version} + + org.opendevstack.apiservice + core-security + ${project.version} + + io.jsonwebtoken jjwt-api 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..0feb0ad 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,10 +22,18 @@ 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); } + public static CreateComponentResponse unprocessableEntity(String path, String message, ComponentErrorKey errorKey) { + return buildResponse(HttpStatus.UNPROCESSABLE_ENTITY, errorKey, path, message); + } + public static CreateComponentResponse internalError(String path, String message, ComponentErrorKey errorKey) { return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, 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..a78afeb 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java @@ -3,7 +3,6 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.opendevstack.apiservice.project.api.ProjectComponentsApi; -import org.opendevstack.apiservice.project.exception.ComponentCreationException; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; import org.opendevstack.apiservice.project.facade.ComponentsFacade; import org.opendevstack.apiservice.project.mapper.ComponentResponseMapper; @@ -15,8 +14,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.UUID; - @RestController @AllArgsConstructor @Slf4j @@ -31,20 +28,18 @@ 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()); + public ResponseEntity getProjectComponent(String projectId, String componentId) { + Component component = componentsFacade.getProjectComponent(projectId, componentId); if (component == null) { throw new ComponentNotFoundException( String.format("Component '%s' not found for project '%s'", componentId, projectId) @@ -54,4 +49,5 @@ public ResponseEntity getProjectComponent(String projectId, UUID comp 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..add5c7f 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,9 +4,13 @@ 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.ComponentBadRequestException; import org.opendevstack.apiservice.project.exception.ComponentCreationException; import org.opendevstack.apiservice.project.exception.ComponentErrorKey; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.exception.ComponentRetrievalException; +import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.CreateComponentResponse; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -138,6 +142,54 @@ public ResponseEntity handleComponentCreationException( return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); } + @ExceptionHandler(ComponentRetrievalException.class) + public ResponseEntity handleComponentRetrievalException( + ComponentRetrievalException ex, + HttpServletRequest request) { + + log.error("Component retrieval failed: {}", ex.getMessage(), ex); + + CreateComponentResponse response = ComponentsResponseFactory.internalError( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.INTERNAL_ERROR + ); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + + @ExceptionHandler(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(ComponentBadRequestException.class) + public ResponseEntity handleComponentBadRequestException( + ComponentBadRequestException ex, + HttpServletRequest request) { + + log.warn("Bad request from downstream service: {}", ex.getMessage()); + + CreateComponentResponse response = ComponentsResponseFactory.unprocessableEntity( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.INVALID_PARAMETERS + ); + + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).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/CatalogItemNotFoundException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/CatalogItemNotFoundException.java new file mode 100644 index 0000000..9b66377 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/CatalogItemNotFoundException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class CatalogItemNotFoundException extends RuntimeException { + + public CatalogItemNotFoundException(String message) { + super(message); + } + + public CatalogItemNotFoundException(String message, Exception e) { + super(message, e); + } +} 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/ComponentBadRequestException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentBadRequestException.java new file mode 100644 index 0000000..10deaec --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentBadRequestException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentBadRequestException extends RuntimeException { + + public ComponentBadRequestException(String message) { + super(message); + } + + public ComponentBadRequestException(String message, Throwable cause) { + super(message, cause); + } +} 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/exception/ComponentRetrievalException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentRetrievalException.java new file mode 100644 index 0000000..655c5be --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentRetrievalException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentRetrievalException extends RuntimeException { + + public ComponentRetrievalException(String message) { + super(message); + } + + public ComponentRetrievalException(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/facade/ComponentsFacade.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java index 42bab0e..3888310 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,23 @@ 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.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.service.CatalogItemOperations; import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.opendevstack.apiservice.project.exception.CatalogItemNotFoundException; +import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException; +import org.opendevstack.apiservice.project.exception.ComponentBadRequestException; import org.opendevstack.apiservice.project.exception.ComponentCreationException; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.exception.ComponentRetrievalException; 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; @@ -24,25 +32,134 @@ public class ComponentsFacade { private final MarketplaceMapper marketplaceMapper; public Component getProjectComponent(String projectId, String componentId) { - ProjectComponent marketplaceComponent = marketplaceExternalService.getProjectComponent(projectId, componentId); - if (marketplaceComponent == null) { - log.info("Marketplace component with id {} not found", componentId); - throw new ComponentNotFoundException( - String.format("Component '%s' not found for project '%s'", componentId, projectId) + try { + ProjectComponentExtendedInfo marketplaceComponent = marketplaceExternalService.getProjectComponent(projectId, componentId); + if (marketplaceComponent == null) { + log.info("Marketplace component with id {} not found", componentId); + throw new ComponentNotFoundException( + String.format("Component '%s' not found for project '%s'", componentId, projectId) + ); + } + String catalogItemId = CatalogItemOperations.buildCatalogItemId(marketplaceComponent); + CatalogItem catalogItem = marketplaceExternalService.getCatalogItem(catalogItemId); + if (catalogItem == null) { + log.info("Catalog item with id {} not found", catalogItemId); + throw new CatalogItemNotFoundException( + String.format("Catalog item with id '%s' not found", catalogItemId) + ); + } + Component component = marketplaceMapper.mapMarketplaceComponentToV0Component(marketplaceComponent, catalogItem); + log.info("Marketplace v0 component retrieved: {}", component); + return component; + } catch (MarketplaceException e) { + log.error("Failed to retrieve component with id {} for project with id {}: {}", componentId, projectId, e.getMessage(), e); + throw new ComponentRetrievalException( + String.format("Failed to retrieve component '%s' for project '%s': %s", componentId, projectId, e.getMessage()), e ); } - 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 { + CatalogItem catalogItem = resolveCatalogItem(createComponentRequest); + List createComponentParameterList = marketplaceMapper + .mapCreateComponentRequestToCreateComponentParameterList(createComponentRequest, catalogItem); + 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); + } + if (isBadRequestCause(e)) { + String downstreamMessage = extractHttpErrorMessage(e); + throw new ComponentBadRequestException(downstreamMessage, 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); + } + + /** + * Resolves the catalog item that matches the requested {@code productId} (interpreted as the + * Marketplace catalog item slug). Returns {@code null} when the request or product id is missing, + * when the catalog item cannot be found, or when the lookup fails – callers must tolerate a + * missing catalog item and fall back to default parameter handling. + */ + private CatalogItem resolveCatalogItem(CreateComponentRequest createComponentRequest) { + if (createComponentRequest == null || createComponentRequest.getProductId() == null + || createComponentRequest.getProductId().isBlank()) { + return null; + } + String slug = createComponentRequest.getProductId(); + try { + CatalogItem catalogItem = marketplaceExternalService.getCatalogItemBySlug(slug); + if (catalogItem == null) { + log.warn("No catalog item found for slug '{}'; provisioning will fall back to default parameter types", slug); + } + return catalogItem; + } catch (MarketplaceException e) { + log.warn("Failed to retrieve catalog item for slug '{}': {}. Provisioning will fall back to default parameter types", + slug, e.getMessage()); + return null; + } + } + + private boolean isConflictCause(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof HttpClientErrorException.Conflict) { + return true; + } + current = current.getCause(); + } + return false; + } + + private boolean isBadRequestCause(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof HttpClientErrorException.UnprocessableEntity + || current instanceof HttpClientErrorException.BadRequest) { + return true; + } + current = current.getCause(); + } + return false; + } + + private String extractHttpErrorMessage(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof HttpClientErrorException httpError) { + return httpError.getResponseBodyAsString(); + } + current = current.getCause(); + } + return throwable.getMessage(); + } + + 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..2a0eec4 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java @@ -1,73 +1,97 @@ package org.opendevstack.apiservice.project.mapper; -import org.mapstruct.IterableMapping; import org.mapstruct.Mapper; -import org.mapstruct.Mapping; -import org.mapstruct.Named; -import org.opendevstack.apiservice.externalservice.marketplace.model.CreateComponentParameter; -import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItemUserAction; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItemUserActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; +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.HashMap; 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 = "resultTraceback", ignore = true) - Component mapMarketplaceComponentToV0Component(ProjectComponent source); + String DEFAULT_PARAMETER_TYPE = "string"; - default List mapCreateComponentRequestToCreateComponentParameterList(CreateComponentRequest createComponentRequest) { - if (createComponentRequest == null || createComponentRequest.getParams() == null) { - return List.of(); + default Component mapMarketplaceComponentToV0Component(ProjectComponentExtendedInfo source, CatalogItem catalogItem) throws MarketplaceException { + Component component = new Component(); + component.setId(source.getComponentId()); + component.setEnvironment(EnvironmentsDTO.DEV); // Env is always DEV so we hardcode it as such + component.setRepositoryURL(source.getComponentUrl()); + component.setComponentType(""); // We agreed to hardcode the type as empty + component.setStatus(StatusMap.toOldStatus(source.getStatus())); + if (component.getStatus() == null) { + throw new MarketplaceException("No status mapping found for status " + source.getStatus()); + } + if (catalogItem != null) { + component.setProductId(catalogItem.getId()); + component.setProductName(catalogItem.getTitle()); + component.setProductDescription(catalogItem.getShortDescription()); } - return mapEntriesToCreateComponentParameterList(createComponentRequest.getParams().entrySet().stream().toList()); + if (source.getParameters() != null) { + source.getParameters().forEach( + param -> component.putParamsItem(param.getName(), param.getValues()) + ); + } + return component; } - @IterableMapping(qualifiedByName = "toCreateComponentParameter") - List mapEntriesToCreateComponentParameterList(List> entries); + default List mapCreateComponentRequestToCreateComponentParameterList( + CreateComponentRequest createComponentRequest, CatalogItem catalogItem) { + if (createComponentRequest == null) { + return List.of(); + } + + Map parameterTypesByName = buildParameterTypeIndex(catalogItem); + + List parameters = new ArrayList<>(); + parameters.add(createParameter("component_id", createComponentRequest.getName(), DEFAULT_PARAMETER_TYPE)); + parameters.add(createParameter("catalog_item_slug", createComponentRequest.getProductId(), DEFAULT_PARAMETER_TYPE)); - @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); + if (createComponentRequest.getParams() != null && !createComponentRequest.getParams().isEmpty()) { + createComponentRequest.getParams().forEach((name, value) -> { + String type = parameterTypesByName.getOrDefault(name, DEFAULT_PARAMETER_TYPE); + parameters.add(createParameter(name, value, type)); + }); + } - @Named("uuidToString") - default String uuidToString(UUID sourceId) { - return sourceId != null ? sourceId.toString() : null; + return parameters; } - @Named("toComponentStatus") - default ComponentsStatusDTO toComponentStatus(String sourceStatus) { - if (sourceStatus == null || sourceStatus.isBlank()) { - return null; - } - try { - return ComponentsStatusDTO.fromValue(sourceStatus); - } catch (IllegalArgumentException ex) { - return null; - } + default ProvisionActionParameter createParameter(String name, Object value, String type) { + return new ProvisionActionParameter().name(name).type(type).value(value); } - @Named("toEnvironment") - default EnvironmentsDTO toEnvironment(String sourceEnv) { - if (sourceEnv == null || sourceEnv.isBlank()) { - return null; + /** + * Builds a map of parameter name -> type from the catalog item user actions. + * The type comes from the {@link CatalogItemUserActionParameter#getType()} value + * (e.g. {@code string}, {@code boolean}, {@code multiplelist}, {@code singlelist}, ...). + */ + private static Map buildParameterTypeIndex(CatalogItem catalogItem) { + Map index = new HashMap<>(); + if (catalogItem == null || catalogItem.getUserActions() == null) { + return index; } - try { - return EnvironmentsDTO.fromValue(sourceEnv); - } catch (IllegalArgumentException ex) { - return null; + for (CatalogItemUserAction action : catalogItem.getUserActions()) { + if (action == null || action.getParameters() == null) { + continue; + } + for (CatalogItemUserActionParameter param : action.getParameters()) { + if (param == null || param.getName() == null || param.getType() == null) { + continue; + } + index.putIfAbsent(param.getName(), param.getType()); + } } + return index; } } diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/StatusMap.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/StatusMap.java new file mode 100644 index 0000000..cf9816b --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/StatusMap.java @@ -0,0 +1,26 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.opendevstack.apiservice.project.model.ComponentsStatusDTO; + +import java.util.HashMap; +import java.util.Map; + +public class StatusMap { + + private StatusMap() { + } + + static final Map STATUS_MAP = new HashMap<>(); + + static { + STATUS_MAP.put("CREATING", ComponentsStatusDTO.RUNNING); + STATUS_MAP.put("CREATED", ComponentsStatusDTO.READY); + STATUS_MAP.put("FAILED", ComponentsStatusDTO.FAILED); + STATUS_MAP.put("DELETING", ComponentsStatusDTO.DELETING); + STATUS_MAP.put("UNKNOWN", ComponentsStatusDTO.UNKNOWN); + } + + static ComponentsStatusDTO toOldStatus(String status) { + return STATUS_MAP.get(status); + } +} 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..b8c7748 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java @@ -1,31 +1,38 @@ package org.opendevstack.apiservice.project.facade; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mapstruct.factory.Mappers; import org.mockito.Mock; -import org.mockito.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.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; 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.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestCatalogItem; 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); @@ -35,36 +42,31 @@ class ComponentsFacadeTest { private ComponentsFacade componentsFacade; - private AutoCloseable openMocks; - @BeforeEach void setup() { - openMocks = MockitoAnnotations.openMocks(this); componentsFacade = new ComponentsFacade(marketplaceExternalService, marketplaceMapper); } - @AfterEach - void tearDown() throws Exception { - openMocks.close(); - } - @Test - void 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 { + ProjectComponentExtendedInfo marketplaceComponent = buildTestMarketplaceComponent(); + CatalogItem testCatalogItem = buildTestCatalogItem(); when(marketplaceExternalService.getProjectComponent("testProject", "testComponent")) .thenReturn(marketplaceComponent); + when(marketplaceExternalService.getCatalogItem(anyString())) + .thenReturn(testCatalogItem); 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 +77,47 @@ 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); - 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)); + 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); - 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/mapper/MarketplaceMapperTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapperTest.java new file mode 100644 index 0000000..6495d05 --- /dev/null +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapperTest.java @@ -0,0 +1,107 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.junit.jupiter.api.Test; +import org.mapstruct.factory.Mappers; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItemUserAction; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItemUserActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionParameter; +import org.opendevstack.apiservice.project.model.CreateComponentRequest; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class MarketplaceMapperTest { + + private final MarketplaceMapper mapper = Mappers.getMapper(MarketplaceMapper.class); + + @Test + void mapCreateComponentRequestToCreateComponentParameterList_resolvesTypeFromCatalogUserActions() { + CreateComponentRequest request = new CreateComponentRequest(); + request.setName("test-amp-test-four"); + request.setProductId("catest_external-lists-item"); + + List projectGroup = List.of( + "CN=BI-AS-ATLASSIAN-P-EDPI-STAKEHOLDER,OU=BIDS-managed,DC=eu,DC=boehringer,DC=com", + "CN=BI-AS-ATLASSIAN-P-EDPI-MANAGER,OU=BIDS-managed,DC=eu,DC=boehringer,DC=com"); + request.setParams(Map.of( + "requestor_email", "user@example.com", + "Project_Group", projectGroup, + "enable_feature", true + )); + + CatalogItem catalogItem = new CatalogItem(); + catalogItem.setUserActions(List.of(buildProvisionAction( + param("requestor_email", "string"), + param("Project_Group", "multiplelist"), + param("enable_feature", "boolean") + ))); + + List result = + mapper.mapCreateComponentRequestToCreateComponentParameterList(request, catalogItem); + + assertThat(result).extracting(ProvisionActionParameter::getName) + .containsExactlyInAnyOrder("component_id", "catalog_item_slug", + "requestor_email", "Project_Group", "enable_feature"); + + ProvisionActionParameter projectGroupParam = findByName(result, "Project_Group"); + assertThat(projectGroupParam.getType()).isEqualTo("multiplelist"); + assertThat(projectGroupParam.getValue()).isEqualTo(projectGroup); + + ProvisionActionParameter emailParam = findByName(result, "requestor_email"); + assertThat(emailParam.getType()).isEqualTo("string"); + assertThat(emailParam.getValue()).isEqualTo("user@example.com"); + + ProvisionActionParameter booleanParam = findByName(result, "enable_feature"); + assertThat(booleanParam.getType()).isEqualTo("boolean"); + assertThat(booleanParam.getValue()).isEqualTo(true); + + ProvisionActionParameter componentId = findByName(result, "component_id"); + assertThat(componentId.getType()).isEqualTo("string"); + assertThat(componentId.getValue()).isEqualTo("test-amp-test-four"); + + ProvisionActionParameter slug = findByName(result, "catalog_item_slug"); + assertThat(slug.getType()).isEqualTo("string"); + assertThat(slug.getValue()).isEqualTo("catest_external-lists-item"); + } + + @Test + void mapCreateComponentRequestToCreateComponentParameterList_fallsBackToStringWhenCatalogItemMissing() { + CreateComponentRequest request = new CreateComponentRequest(); + request.setName("test-component"); + request.setProductId("some-slug"); + request.setParams(Map.of("anything", "value")); + + List result = + mapper.mapCreateComponentRequestToCreateComponentParameterList(request, null); + + ProvisionActionParameter anything = findByName(result, "anything"); + assertThat(anything.getType()).isEqualTo("string"); + assertThat(anything.getValue()).isEqualTo("value"); + } + + @Test + void mapCreateComponentRequestToCreateComponentParameterList_returnsEmptyWhenRequestNull() { + assertThat(mapper.mapCreateComponentRequestToCreateComponentParameterList(null, null)).isEmpty(); + } + + private static CatalogItemUserAction buildProvisionAction(CatalogItemUserActionParameter... params) { + CatalogItemUserAction action = new CatalogItemUserAction(); + action.setId("PROVISION"); + action.setParameters(List.of(params)); + return action; + } + + private static CatalogItemUserActionParameter param(String name, String type) { + CatalogItemUserActionParameter p = new CatalogItemUserActionParameter(); + p.setName(name); + p.setType(type); + return p; + } + + private static ProvisionActionParameter findByName(List list, String name) { + return list.stream().filter(p -> name.equals(p.getName())).findFirst().orElseThrow(); + } +} 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..a9c059f 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.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.ComponentsStatusDTO; import org.opendevstack.apiservice.project.model.CreateComponentRequest; @@ -25,15 +26,24 @@ public static Component buildTestComponent() { return component; } - public static ProjectComponent buildTestMarketplaceComponent() { - ProjectComponent component = new ProjectComponent(); - component.setComponentId(UUID.randomUUID()); - component.setStatus("RUNNING"); - component.setCanBeDeleted(false); + public static ProjectComponentExtendedInfo buildTestMarketplaceComponent() { + ProjectComponentExtendedInfo component = new ProjectComponentExtendedInfo(); + component.setComponentId(UUID.randomUUID().toString()); + component.setStatus("CREATING"); component.setComponentUrl("http://test.component.url"); + component.setCatalogItemId("cHJvamVjdHMvVEVTVC9yZXBvcy9DYXRhbG9nSXRlbS55YW1s"); + component.setCatalogItemRef("P2F0PXJlZnMvaGVhZHMvbWFzdGVy"); return component; } + public static CatalogItem buildTestCatalogItem() { + CatalogItem catalogItem = new CatalogItem(); + catalogItem.setId(UUID.randomUUID().toString()); + catalogItem.setTitle("Test Catalog Item"); + catalogItem.setShortDescription("This is a test catalog item"); + return catalogItem; + } + public static CreateComponentRequest buildTestCreateComponentRequest() { CreateComponentRequest request = new CreateComponentRequest(); request.setName("testcomponent"); diff --git a/api-project/pom.xml b/api-project/pom.xml index 3a7962e..7172641 100644 --- a/api-project/pom.xml +++ b/api-project/pom.xml @@ -57,6 +57,12 @@ persistence ${project.version} + + + org.opendevstack.apiservice + core-security + ${project.version} + io.jsonwebtoken diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java index c9bc530..54c3327 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java @@ -3,11 +3,11 @@ import jakarta.validation.Valid; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.core.security.jwt.JwtUtils; import org.opendevstack.apiservice.project.api.ProjectsApi; import org.opendevstack.apiservice.project.facade.ProjectsFacade; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; -import org.opendevstack.apiservice.project.util.SecurityUtils; import org.opendevstack.apiservice.project.validation.ProjectRequestValidator; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -38,7 +38,7 @@ public class ProjectController implements ProjectsApi { @Override public ResponseEntity createProject(@Valid @RequestBody CreateProjectRequest createProjectRequest) { projectRequestValidator.validate(createProjectRequest); - UUID clientId = SecurityUtils.getClientId(); + UUID clientId = JwtUtils.getClientId(); CreateProjectResponse projectResponse = projectsFacade.createProject(createProjectRequest, clientId); projectResponse.setLocation(API_BASE_PATH + "/" + projectResponse.getProjectKey()); diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java b/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java deleted file mode 100644 index bb926a9..0000000 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.opendevstack.apiservice.project.util; - -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; - -import java.util.UUID; - -public class SecurityUtils { - - private SecurityUtils() { - // to avoid instantiation - } - - public static UUID getClientId() { - Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - - if (principal instanceof Jwt jwt) { - String clientId = jwt.getClaimAsString("azp"); - if (clientId == null || clientId.isBlank()) { - clientId = jwt.getClaimAsString("appid"); - } - - if (clientId == null || clientId.isBlank()) { - throw new InvalidBearerTokenException("Client ID not found in token claims"); - } - - return UUID.fromString(clientId); - } else { - throw new InvalidBearerTokenException("Invalid authentication token"); - } - } -} diff --git a/application.yaml b/application.yaml index bfaa024..deb7058 100644 --- a/application.yaml +++ b/application.yaml @@ -80,6 +80,13 @@ app: - /actuator/health - /actuator/info - /api/v1/projects/*/platforms + obo: + # Azure AD token endpoint used for On-Behalf-Of token exchange. + token-url: ${OBO_TOKEN_URL:https://login.microsoftonline.com/${AZURE_TENANT_ID:}/oauth2/v2.0/token} + # Client ID of this application's Azure AD app registration. + client-id: ${OBO_CLIENT_ID} + # Client secret for OBO exchange. Must be injected via environment variable or secret store. + client-secret: ${OBO_CLIENT_SECRET} # Spring Boot Actuator configuration. # Restrict these endpoints in production if they expose operational details. @@ -278,3 +285,22 @@ externalservices: projects-info-service: # Base URL of the downstream Projects Info Service consumed by this application. base-url: ${PROJECTS_INFO_SERVICE_BASE_URL:http://localhost:8081} + + jira: + # Name of the default Jira instance. + default-instance: ${JIRA_DEFAULT_INSTANCE:jira} + instances: + jira: + base-url: ${JIRA_JIRA_DEV_BASE_URL:https://jira.example.com} + bearer-token: ${JIRA_JIRA_DEV_BEARER_TOKEN:} + connection-timeout: ${JIRA_JIRA_DEV_CONNECTION_TIMEOUT:30000} + read-timeout: ${JIRA_JIRA_DEV_READ_TIMEOUT:30000} + trust-all-certificates: ${JIRA_JIRA_DEV_TRUST_ALL:false} + + marketplace: + instances: + default: + project-components-base-url: ${MARKETPLACE_PROJECT_COMPONENTS_BASE_URL} + provisioner-actions-base-url: ${MARKETPLACE_PROVISIONER_ACTIONS_BASE_URL:} + obo-scope: ${MARKETPLACE_OBO_SCOPE:} + trust-all-certificates: ${MARKETPLACE_TRUST_ALL_CERTS:false} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtUtils.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtUtils.java new file mode 100644 index 0000000..3c90319 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtUtils.java @@ -0,0 +1,68 @@ +package org.opendevstack.apiservice.core.security.jwt; + +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; + +import java.util.UUID; + +public final class JwtUtils { + + private JwtUtils() { + } + + /** + * Extracts the raw JWT token value from the current SecurityContext. + * + * @return the JWT token string + * @throws InvalidBearerTokenException if the principal is not a JWT + */ + public static String getTokenValue() { + Object principal = currentPrincipal(); + if (principal instanceof Jwt jwt) { + return jwt.getTokenValue(); + } + throw new InvalidBearerTokenException("Invalid authentication token"); + } + + private static Object currentPrincipal() { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + throw new InvalidBearerTokenException("No authentication present in security context"); + } + Object principal = authentication.getPrincipal(); + if (principal == null) { + throw new InvalidBearerTokenException("No principal present in security context"); + } + return principal; + } + + /** + * Extracts the Azure AD client ID from the current SecurityContext JWT. + * Checks the {@code azp} claim first, then falls back to {@code appid}. + * + * @return the client ID as UUID + * @throws InvalidBearerTokenException if the principal is not a JWT or no client ID claim is found + */ + public static UUID getClientId() { + Object principal = currentPrincipal(); + if (principal instanceof Jwt jwt) { + return extractClientId(jwt); + } + throw new InvalidBearerTokenException("Invalid authentication token"); + } + + /** + * Extracts the Azure AD client ID from the given JWT. + */ + public static UUID extractClientId(Jwt jwt) { + String clientId = jwt.getClaimAsString("azp"); + if (clientId == null || clientId.isBlank()) { + clientId = jwt.getClaimAsString("appid"); + } + if (clientId == null || clientId.isBlank()) { + throw new InvalidBearerTokenException("Client ID not found in token claims"); + } + return UUID.fromString(clientId); + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenException.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenException.java new file mode 100644 index 0000000..bbb797a --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.core.security.obo; + +public class OboTokenException extends RuntimeException { + + public OboTokenException(String message) { + super(message); + } + + public OboTokenException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenProperties.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenProperties.java new file mode 100644 index 0000000..0454b89 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenProperties.java @@ -0,0 +1,17 @@ +package org.opendevstack.apiservice.core.security.obo; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "app.security.obo") +@Data +public class OboTokenProperties { + + private String tokenUrl; + + private String clientId; + + private String clientSecret; +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenResponse.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenResponse.java new file mode 100644 index 0000000..5894738 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenResponse.java @@ -0,0 +1,25 @@ +package org.opendevstack.apiservice.core.security.obo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class OboTokenResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("expires_in") + private int expiresIn; + + private String scope; +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenService.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenService.java new file mode 100644 index 0000000..6a9a7a2 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenService.java @@ -0,0 +1,68 @@ +package org.opendevstack.apiservice.core.security.obo; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +@Service +@Slf4j +public class OboTokenService { + + private static final String GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"; + + private final OboTokenProperties properties; + private final RestTemplate restTemplate; + + @Autowired + public OboTokenService(OboTokenProperties properties, RestTemplate restTemplate) { + this.properties = properties; + this.restTemplate = restTemplate; + } + + /** + * Exchanges the given JWT assertion for an OBO (On-Behalf-Of) access token. + * + * @param assertion the incoming JWT token value (from the original request) + * @param scope the target API scope (e.g. {@code api:///Api.Access}) + * @return the OBO access token string + * @throws OboTokenException if the token exchange fails + */ + public String exchangeToken(String assertion, String scope) { + log.debug("Exchanging JWT for OBO token with scope '{}'", scope); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", GRANT_TYPE); + body.add("client_id", properties.getClientId()); + body.add("client_secret", properties.getClientSecret()); + body.add("assertion", assertion); + body.add("requested_token_use", "on_behalf_of"); + body.add("scope", scope); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + HttpEntity> request = new HttpEntity<>(body, headers); + + try { + ResponseEntity response = + restTemplate.postForEntity(properties.getTokenUrl(), request, OboTokenResponse.class); + + if (response.getBody() == null || response.getBody().getAccessToken() == null) { + throw new OboTokenException("OBO token response body or access_token is null"); + } + + log.debug("OBO token obtained successfully, expires in {} seconds", response.getBody().getExpiresIn()); + return response.getBody().getAccessToken(); + } catch (RestClientException e) { + throw new OboTokenException("Failed to exchange JWT for OBO token: " + e.getMessage(), e); + } + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/JwtUtilsTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/JwtUtilsTest.java new file mode 100644 index 0000000..76903b4 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/JwtUtilsTest.java @@ -0,0 +1,132 @@ +package org.opendevstack.apiservice.core.security.jwt; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class JwtUtilsTest { + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void get_token_value_returns_jwt_token_string() { + // GIVEN + Jwt jwt = buildJwt("eyJhbGciOiJSUzI1NiJ9.test", Map.of("azp", "client-a")); + setSecurityContext(jwt); + + // WHEN + String token = JwtUtils.getTokenValue(); + + // THEN + assertEquals("eyJhbGciOiJSUzI1NiJ9.test", token); + } + + @Test + void get_token_value_throws_when_principal_is_not_jwt() { + // GIVEN + SecurityContext ctx = mock(SecurityContext.class); + var auth = mock(org.springframework.security.core.Authentication.class); + when(auth.getPrincipal()).thenReturn("not-a-jwt"); + when(ctx.getAuthentication()).thenReturn(auth); + SecurityContextHolder.setContext(ctx); + + // WHEN / THEN + assertThrows(InvalidBearerTokenException.class, JwtUtils::getTokenValue); + } + + @Test + void get_client_id_returns_azp_claim() { + // GIVEN + String clientId = "00000000-0000-0000-0000-000000000001"; + Jwt jwt = buildJwt("token", Map.of("azp", clientId)); + setSecurityContext(jwt); + + // WHEN + UUID result = JwtUtils.getClientId(); + + // THEN + assertEquals(UUID.fromString(clientId), result); + } + + @Test + void get_client_id_falls_back_to_appid_when_azp_is_blank() { + // GIVEN + String clientId = "00000000-0000-0000-0000-000000000002"; + Jwt jwt = buildJwt("token", Map.of("azp", "", "appid", clientId)); + setSecurityContext(jwt); + + // WHEN + UUID result = JwtUtils.getClientId(); + + // THEN + assertEquals(UUID.fromString(clientId), result); + } + + @Test + void get_client_id_throws_when_no_client_claim() { + // GIVEN + Jwt jwt = buildJwt("token", Map.of("sub", "user")); + setSecurityContext(jwt); + + // WHEN / THEN + assertThrows(InvalidBearerTokenException.class, JwtUtils::getClientId); + } + + @Test + void extract_client_id_from_jwt_with_azp() { + // GIVEN + String clientId = "00000000-0000-0000-0000-000000000003"; + Jwt jwt = buildJwt("token", Map.of("azp", clientId)); + + // WHEN + UUID result = JwtUtils.extractClientId(jwt); + + // THEN + assertEquals(UUID.fromString(clientId), result); + } + + @Test + void extract_client_id_from_jwt_with_appid_fallback() { + // GIVEN + String clientId = "00000000-0000-0000-0000-000000000004"; + Jwt jwt = buildJwt("token", Map.of("appid", clientId)); + + // WHEN + UUID result = JwtUtils.extractClientId(jwt); + + // THEN + assertEquals(UUID.fromString(clientId), result); + } + + private Jwt buildJwt(String tokenValue, Map claims) { + Jwt.Builder builder = Jwt.withTokenValue(tokenValue) + .header("alg", "RS256") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(3600)); + claims.forEach(builder::claim); + return builder.build(); + } + + private void setSecurityContext(Jwt jwt) { + JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); + SecurityContext ctx = SecurityContextHolder.createEmptyContext(); + ctx.setAuthentication(auth); + SecurityContextHolder.setContext(ctx); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/obo/OboTokenServiceTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/obo/OboTokenServiceTest.java new file mode 100644 index 0000000..ad9d0ca --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/obo/OboTokenServiceTest.java @@ -0,0 +1,129 @@ +package org.opendevstack.apiservice.core.security.obo; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OboTokenServiceTest { + + @Mock + private RestTemplate restTemplate; + + private OboTokenProperties properties; + + private OboTokenService sut; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + properties = new OboTokenProperties(); + properties.setTokenUrl("https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token"); + properties.setClientId("test-client-id"); + properties.setClientSecret("test-client-secret"); + sut = new OboTokenService(properties, restTemplate); + } + + @Test + void exchange_token_returns_access_token_on_success() { + // GIVEN + String assertion = "jwt-assertion-value"; + String scope = "api://target-app/Api.Access"; + OboTokenResponse tokenResponse = new OboTokenResponse(); + tokenResponse.setAccessToken("obo-access-token"); + tokenResponse.setTokenType("Bearer"); + tokenResponse.setExpiresIn(3600); + tokenResponse.setScope(scope); + + when(restTemplate.postForEntity(eq(properties.getTokenUrl()), any(HttpEntity.class), eq(OboTokenResponse.class))) + .thenReturn(new ResponseEntity<>(tokenResponse, HttpStatus.OK)); + + // WHEN + String result = sut.exchangeToken(assertion, scope); + + // THEN + assertEquals("obo-access-token", result); + verify(restTemplate).postForEntity(eq(properties.getTokenUrl()), any(HttpEntity.class), eq(OboTokenResponse.class)); + } + + @Test + @SuppressWarnings("unchecked") + void exchange_token_sends_correct_form_parameters() { + // GIVEN + String assertion = "my-jwt"; + String scope = "api://app/scope"; + OboTokenResponse tokenResponse = new OboTokenResponse(); + tokenResponse.setAccessToken("token"); + + ArgumentCaptor>> captor = ArgumentCaptor.forClass(HttpEntity.class); + when(restTemplate.postForEntity(anyString(), captor.capture(), eq(OboTokenResponse.class))) + .thenReturn(new ResponseEntity<>(tokenResponse, HttpStatus.OK)); + + // WHEN + sut.exchangeToken(assertion, scope); + + // THEN + MultiValueMap body = captor.getValue().getBody(); + assertNotNull(body); + assertEquals("urn:ietf:params:oauth:grant-type:jwt-bearer", body.getFirst("grant_type")); + assertEquals("test-client-id", body.getFirst("client_id")); + assertEquals("test-client-secret", body.getFirst("client_secret")); + assertEquals("my-jwt", body.getFirst("assertion")); + assertEquals("on_behalf_of", body.getFirst("requested_token_use")); + assertEquals("api://app/scope", body.getFirst("scope")); + } + + @Test + void exchange_token_throws_obo_exception_when_response_body_is_null() { + // GIVEN + when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(OboTokenResponse.class))) + .thenReturn(new ResponseEntity<>(null, HttpStatus.OK)); + + // WHEN / THEN + OboTokenException ex = assertThrows(OboTokenException.class, + () -> sut.exchangeToken("jwt", "scope")); + assertTrue(ex.getMessage().contains("null")); + } + + @Test + void exchange_token_throws_obo_exception_when_access_token_is_null() { + // GIVEN + OboTokenResponse response = new OboTokenResponse(); + response.setAccessToken(null); + + when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(OboTokenResponse.class))) + .thenReturn(new ResponseEntity<>(response, HttpStatus.OK)); + + // WHEN / THEN + assertThrows(OboTokenException.class, () -> sut.exchangeToken("jwt", "scope")); + } + + @Test + void exchange_token_throws_obo_exception_on_rest_client_error() { + // GIVEN + when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(OboTokenResponse.class))) + .thenThrow(new RestClientException("Connection refused")); + + // WHEN / THEN + OboTokenException ex = assertThrows(OboTokenException.class, + () -> sut.exchangeToken("jwt", "scope")); + assertTrue(ex.getMessage().contains("Connection refused")); + } +} 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..6bf9eb3 --- /dev/null +++ b/external-service-marketplace/openapi/openapi-component_catalog-v1.0.0.yaml @@ -0,0 +1,1366 @@ +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: CatalogHealth + description: Public actuator health endpoint. + - 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: + /actuator/health: + get: + tags: + - CatalogHealth + summary: Health check endpoint + description: Public actuator health endpoint. + operationId: getCatalogHealth + security: [] + responses: + "200": + description: Service health status. + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: UP + groups: + type: array + items: + type: string + required: + - status + + /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' + /project/{projectKey}/component/{componentId}: + get: + tags: + - Project-components + summary: Returns the extended information of a project component given both its project key and component ID in the Bitbucket repository. + operationId: getProjectComponentById + parameters: + - name: projectKey + in: path + description: project key. + required: true + schema: + type: string + - name: componentId + in: path + description: component ID. + required: true + schema: + type: string + responses: + "200": + description: The extended information of a project component. + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectComponentExtendedInfo' + "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' + ProjectComponentParameter: + properties: + name: + type: string + example: 'environment' + values: + type: array + items: + type: string + example: + - 'dev' + - 'test' + ProjectComponentExtendedInfo: + properties: + componentId: + type: string + example: 'nextjs-basic-app' + catalogItemId: + type: string + example: 'some-encoded-info' + catalogItemRef: + type: string + example: 'more-encoded-info' + status: + type: string + example: 'CREATING' + componentUrl: + type: string + example: 'https://bitbucket.com/projects/CATALOGS/repos/project-components/browse/projects' + parameters: + type: array + items: + $ref: '#/components/schemas/ProjectComponentParameter' + 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..4de634a --- /dev/null +++ b/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml @@ -0,0 +1,443 @@ +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: ProvisionerHealth + description: Public actuator health endpoint. + - name: ProvisionerActions + description: ProvisionerAction operations. + - name: ProvisionerMessagesDefinitions + description: Provisioner standardized messages definitions + - name: ProvisionResults + description: Work with project components statuses +paths: + /actuator/health: + get: + tags: + - ProvisionerHealth + summary: Health check endpoint + description: Public actuator health endpoint. + operationId: getProvisionerHealth + security: [] + responses: + "200": + description: Service health status. + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: UP + groups: + type: array + items: + type: string + required: + - status + + /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..bdc0f96 100644 --- a/external-service-marketplace/pom.xml +++ b/external-service-marketplace/pom.xml @@ -21,6 +21,13 @@ ${project.version}
+ + + org.opendevstack.apiservice + core-security + ${project.version} + + org.springframework.boot @@ -87,15 +94,130 @@ 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 + + + + 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 + + + org.openapitools + openapi-generator-maven-plugin + + + generate-marketplace-catalog-client + + generate + + + FILTER=operationId:getProjectComponents|getProjectComponentById|getCatalogItemById|getCatalogItemBySlug|getCatalogHealth + 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|getProvisionerHealth + 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..7796f68 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java @@ -0,0 +1,70 @@ +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); + + log.info("MarketplaceApiClient initialized for instance '{}'", 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); + } + + /** + * Sets the bearer token for authentication on this client. + * This replaces any previously configured bearer token. + * + * @param bearerToken the bearer token to use for subsequent API calls + */ + public void setBearerToken(String bearerToken) { + HttpBearerAuth auth = (HttpBearerAuth) this.apiClient.getAuthentication("bearerAuth"); + auth.setBearerToken(bearerToken); + } +} 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..093825d --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactory.java @@ -0,0 +1,215 @@ +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.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.net.HttpURLConnection; +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, this method will throw a {@link MarketplaceException} + * to avoid ambiguity. The caller should explicitly call {@link #getClient()} to get the default instance client + * in that case. + * + * @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 + */ + 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 a {@link MarketplaceApiClient} for a specific instance with the given bearer token. + * + * @param instanceName Name of the Marketplace instance + * @param bearerToken Bearer token to use for authentication + * @return Configured MarketplaceApiClient with the given bearer token + * @throws MarketplaceException if the instance is not configured + */ + public MarketplaceApiClient getClient(String instanceName, String bearerToken) throws MarketplaceException { + MarketplaceApiClient client = getClient(instanceName); + client.setBearerToken(bearerToken); + return client; + } + + /** + * 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 + */ + 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); + } + + /** + * 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; + if (config.isTrustAllCertificates()) { + log.warn("Trust all certificates is enabled for Marketplace API connection. " + + "This should only be used in development environments!"); + requestFactory = createTrustAllRequestFactory(); + } else { + requestFactory = new SimpleClientHttpRequestFactory(); + } + requestFactory.setConnectTimeout(config.getConnectionTimeout()); + requestFactory.setReadTimeout(config.getReadTimeout()); + restTemplate.setRequestFactory(requestFactory); + + return restTemplate; + } + + /** + * Builds a {@link SimpleClientHttpRequestFactory} that disables certificate and hostname + * verification only for the connections opened by this factory (no JVM-global side effects). + * WARNING: should only be used in development environments. + */ + @SuppressWarnings({"java:S4830", "java:S5527"}) + private SimpleClientHttpRequestFactory createTrustAllRequestFactory() { + final javax.net.ssl.SSLSocketFactory socketFactory; + try { + TrustManager[] trustAllCertificates = new TrustManager[]{ + new X509TrustManager() { + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + public void checkClientTrusted(X509Certificate[] certs, String authType) { + // Intentionally empty - dev only + } + public void checkServerTrusted(X509Certificate[] certs, String authType) { + // Intentionally empty - dev only + } + } + }; + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, trustAllCertificates, new java.security.SecureRandom()); + socketFactory = context.getSocketFactory(); + } catch (NoSuchAlgorithmException | KeyManagementException ex) { + log.error("Failed to configure SSL trust all certificates for Marketplace API", ex); + return new SimpleClientHttpRequestFactory(); + } + + return new SimpleClientHttpRequestFactory() { + @Override + protected void prepareConnection(HttpURLConnection connection, String httpMethod) throws java.io.IOException { + if (connection instanceof HttpsURLConnection httpsConnection) { + httpsConnection.setSSLSocketFactory(socketFactory); + httpsConnection.setHostnameVerifier((hostname, session) -> true); + } + super.prepareConnection(connection, httpMethod); + } + }; + } +} 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..7b170d2 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java @@ -0,0 +1,38 @@ +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; + + /** + * 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; + + /** + * OAuth2 scope used for OBO token exchange when calling this Marketplace instance. + * Example: {@code api:///Api.Access} + */ + private String oboScope; +} \ 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/CatalogItemOperations.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperations.java new file mode 100644 index 0000000..14919d4 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperations.java @@ -0,0 +1,29 @@ +package org.opendevstack.apiservice.externalservice.marketplace.service; + +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public class CatalogItemOperations { + + private CatalogItemOperations() { + } + + public static byte[] encodeId(String id) { + return Base64.getUrlEncoder().encode(id.getBytes(StandardCharsets.UTF_8)); + } + + public static byte[] decodeId(String id) { + return Base64.getUrlDecoder().decode(id); + } + + public static String buildCatalogItemId(ProjectComponentExtendedInfo component) { + if (component == null || component.getCatalogItemId() == null || component.getCatalogItemRef() == null) { + return null; + } + String decodedId = new String(decodeId(component.getCatalogItemId()), StandardCharsets.UTF_8); + String decodedRef = new String(decodeId(component.getCatalogItemRef()), StandardCharsets.UTF_8); + return new String(encodeId(decodedId + decodedRef), StandardCharsets.UTF_8); + } +} 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..fc47b9b 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,44 @@ 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.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; +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; + + ProjectComponentExtendedInfo getProjectComponent(String projectId, String componentId) throws MarketplaceException; + + ProjectComponentExtendedInfo getProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException; + + CatalogItem getCatalogItem(String catalogItemId) throws MarketplaceException; + + CatalogItem getCatalogItem(String instanceName, String catalogItemId) throws MarketplaceException; + + CatalogItem getCatalogItemBySlug(String slug) throws MarketplaceException; + + CatalogItem getCatalogItemBySlug(String instanceName, String slug) 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..1e58a50 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java @@ -0,0 +1,352 @@ +package org.opendevstack.apiservice.externalservice.marketplace.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.core.security.jwt.JwtUtils; +import org.opendevstack.apiservice.core.security.obo.OboTokenService; +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.CatalogHealthApi; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.api.CatalogItemsApi; +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.api.ProvisionerHealthApi; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CreateIncidentAction; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.GetCatalogHealth200Response; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.GetProvisionerHealth200Response; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.NotifyProvisioningStatusUpdateRequest; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; +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 static final String HEALTH_STATUS_UP = "UP"; + + private final MarketplaceApiClientFactory clientFactory; + private final OboTokenService oboTokenService; + + public MarketplaceServiceImpl(MarketplaceApiClientFactory clientFactory, + OboTokenService oboTokenService) { + this.clientFactory = clientFactory; + this.oboTokenService = oboTokenService; + log.info("MarketplaceServiceImpl initialized"); + } + + + @Override + public CatalogItem getCatalogItem(String catalogItemId) throws MarketplaceException { + return getCatalogItem(getDefaultInstance(), catalogItemId); + } + + @Override + public CatalogItem getCatalogItem(String instanceName, String catalogItemId) throws MarketplaceException { + log.debug("Marketplace service GET catalog item with id {} in instance {} ", catalogItemId, instanceName); + try { + MarketplaceApiClient marketplaceClient = getOboAuthenticatedClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + CatalogItemsApi catalogItemsApi = new CatalogItemsApi(apiClient); + apiClient.setBasePath(marketplaceClient.getConfig().getProjectComponentsBaseUrl()); + return catalogItemsApi.getCatalogItemById(catalogItemId); + } catch (HttpClientErrorException.NotFound e) { + log.debug("Catalog item with id '{}' not found in Marketplace instance '{}'", + catalogItemId, instanceName); + return null; + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new MarketplaceException( + String.format("Access denied when getting catalog item '%s' in instance '%s'", + catalogItemId, instanceName), e); + } catch (RestClientException e) { + throw new MarketplaceException( + String.format("Failed to retrieve catalog item '%s' in instance '%s'", + catalogItemId, instanceName), e); + } + } + + @Override + public CatalogItem getCatalogItemBySlug(String slug) throws MarketplaceException { + return getCatalogItemBySlug(getDefaultInstance(), slug); + } + + @Override + public CatalogItem getCatalogItemBySlug(String instanceName, String slug) throws MarketplaceException { + log.debug("Marketplace service GET catalog item with slug {} in instance {} ", slug, instanceName); + try { + MarketplaceApiClient marketplaceClient = getOboAuthenticatedClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + CatalogItemsApi catalogItemsApi = new CatalogItemsApi(apiClient); + apiClient.setBasePath(marketplaceClient.getConfig().getProjectComponentsBaseUrl()); + return catalogItemsApi.getCatalogItemBySlug(slug); + } catch (HttpClientErrorException.NotFound e) { + log.debug("Catalog item with slug '{}' not found in Marketplace instance '{}'", + slug, instanceName); + return null; + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new MarketplaceException( + String.format("Access denied when getting catalog item with slug '%s' in instance '%s'", + slug, instanceName), e); + } catch (RestClientException e) { + throw new MarketplaceException( + String.format("Failed to retrieve catalog item with slug '%s' in instance '%s'", + slug, instanceName), e); + } + } + + @Override + public ProjectComponentExtendedInfo getProjectComponent(String projectId, String componentId) throws MarketplaceException { + return getProjectComponent(getDefaultInstance(), projectId, componentId); + } + + @Override + public ProjectComponentExtendedInfo 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 = getOboAuthenticatedClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + ProjectComponentsApi projectComponentsApi = new ProjectComponentsApi(apiClient); + apiClient.setBasePath(marketplaceClient.getConfig().getProjectComponentsBaseUrl()); + return projectComponentsApi.getProjectComponentById(projectId, componentId); + } 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 = getOboAuthenticatedClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + MarketplaceInstanceConfig config = marketplaceClient.getConfig(); + + String provisionerActionsBaseUrl = config.getProvisionerActionsBaseUrl(); + + ProvisionAction provisionAction = new ProvisionAction(); + provisionAction.setId("PROVISION"); + provisionAction.addParametersItem(new ProvisionActionParameter().name("project_key").type("string").value(projectId)); + + params.forEach(provisionAction::addParametersItem); + + ProvisionerActionsApi provisionerActionsApi = new ProvisionerActionsApi(apiClient); + apiClient.setBasePath(provisionerActionsBaseUrl); + + ProvisionActionResponse response = provisionerActionsApi.triggerProvisionAction(provisionAction); + return !Boolean.TRUE.equals(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 = getOboAuthenticatedClient(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 !Boolean.TRUE.equals(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 = getOboAuthenticatedClient(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 or + * if either public Marketplace health endpoint is not UP. + */ + @Override + public boolean isHealthy() { + Set instances = getAvailableInstances(); + if (instances.isEmpty()) { + log.warn("No Marketplace instances configured - reporting unhealthy"); + return false; + } + + final MarketplaceApiClient marketplaceClient; + try { + marketplaceClient = clientFactory.getClient(getDefaultInstance()); + } catch (MarketplaceException ex) { + log.warn("Could not resolve Marketplace client for health check", ex); + return false; + } + + boolean provisionerHealthy = isProvisionerEndpointUp(marketplaceClient); + if (!provisionerHealthy) { + return false; + } + + return isCatalogEndpointUp(marketplaceClient); + } + + protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { + try { + String provisionerBaseUrl = marketplaceClient.getConfig().getProvisionerActionsBaseUrl(); + ApiClient healthApiClient = marketplaceClient.getApiClient(); + healthApiClient.setBasePath(provisionerBaseUrl); + + ProvisionerHealthApi healthApi = new ProvisionerHealthApi(healthApiClient); + GetProvisionerHealth200Response response = healthApi.getProvisionerHealth(); + return isStatusUp(response != null ? response.getStatus() : null, + "provisionerActionsBaseUrl", provisionerBaseUrl); + } catch (RestClientException ex) { + log.warn("Health check via provisionerActionsBaseUrl failed", ex); + return false; + } + } + + protected boolean isCatalogEndpointUp(MarketplaceApiClient marketplaceClient) { + try { + String catalogBaseUrl = marketplaceClient.getConfig().getProjectComponentsBaseUrl(); + ApiClient healthApiClient = marketplaceClient.getApiClient(); + healthApiClient.setBasePath(catalogBaseUrl); + + CatalogHealthApi healthApi = new CatalogHealthApi(healthApiClient); + GetCatalogHealth200Response response = healthApi.getCatalogHealth(); + return isStatusUp(response != null ? response.getStatus() : null, + "projectComponentsBaseUrl", catalogBaseUrl); + } catch (RestClientException ex) { + log.warn("Health check via projectComponentsBaseUrl failed", ex); + return false; + } + } + + private boolean isStatusUp(String status, String endpointName, String baseUrl) { + boolean healthy = HEALTH_STATUS_UP.equals(status); + if (!healthy) { + log.warn("Health endpoint from {}='{}' returned status '{}'", endpointName, baseUrl, status); + } + return healthy; + } + + @Override + public Set getAvailableInstances() { + return clientFactory.getAvailableInstances(); + } + + @Override + public boolean hasInstance(String instanceName) { + return clientFactory.hasInstance(instanceName); + } + + /** + * Creates a {@link MarketplaceApiClient} authenticated with an OBO token + * obtained from the current request's JWT. + */ + private MarketplaceApiClient getOboAuthenticatedClient(String instanceName) throws MarketplaceException { + MarketplaceApiClient client = clientFactory.getClient(instanceName); + String oboScope = client.getConfig().getOboScope(); + if (oboScope == null || oboScope.isBlank()) { + throw new MarketplaceException( + String.format("OBO scope not configured for Marketplace instance '%s'", instanceName)); + } + String assertion = JwtUtils.getTokenValue(); + final String oboToken; + try { + oboToken = oboTokenService.exchangeToken(assertion, oboScope); + } catch (RuntimeException ex) { + throw new MarketplaceException( + String.format( + "Failed to exchange OBO token for Marketplace instance '%s' with scope '%s'", + instanceName, oboScope), + ex); + } + client.setBearerToken(oboToken); + return client; + } +} 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/CatalogItemOperationsTest.java b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperationsTest.java new file mode 100644 index 0000000..74bce9f --- /dev/null +++ b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperationsTest.java @@ -0,0 +1,39 @@ +package org.opendevstack.apiservice.externalservice.marketplace.service; + +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class CatalogItemOperationsTest { + + @Test + void testBuildCatalogItemId_whenNullComponent_ReturnNull() { + String catalogItemId = CatalogItemOperations.buildCatalogItemId(null); + assertNull(catalogItemId); + } + + @Test + void testBuildCatalogItemId_whenNullValues_ReturnNull() { + ProjectComponentExtendedInfo testComponentExtendedInfo = new ProjectComponentExtendedInfo(); + testComponentExtendedInfo.setCatalogItemId(null); + testComponentExtendedInfo.setCatalogItemRef(null); + + String catalogItemId = CatalogItemOperations.buildCatalogItemId(testComponentExtendedInfo); + assertNull(catalogItemId); + } + + @Test + void testBuildCatalogItemId_whenCorrectValues_ReturnCorrectlyBuiltId() { + ProjectComponentExtendedInfo testComponentExtendedInfo = new ProjectComponentExtendedInfo(); + testComponentExtendedInfo.setCatalogItemId("cHJvamVjdHMvVEVTVC9yZXBvcy9DYXRhbG9nSXRlbS55YW1s"); + testComponentExtendedInfo.setCatalogItemRef("P2F0PXJlZnMvaGVhZHMvbWFzdGVy"); + + String catalogItemId = CatalogItemOperations.buildCatalogItemId(testComponentExtendedInfo); + assertNotNull(catalogItemId); + assertEquals("cHJvamVjdHMvVEVTVC9yZXBvcy9DYXRhbG9nSXRlbS55YW1sP2F0PXJlZnMvaGVhZHMvbWFzdGVy", catalogItemId); + } + +} \ 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..4f6749a --- /dev/null +++ b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java @@ -0,0 +1,956 @@ +package org.opendevstack.apiservice.externalservice.marketplace.service; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.stubbing.OngoingStubbing; +import org.opendevstack.apiservice.core.security.obo.OboTokenService; +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.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionResponse; +import org.opendevstack.apiservice.externalservice.marketplace.service.impl.MarketplaceServiceImpl; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClientException; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +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.ArgumentMatchers.eq; +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 { + + // Generated-API endpoint paths invoked by ApiClient#invokeAPI. These mirror the + // values produced by the OpenAPI generator so tests fail loudly if a path or + // HTTP method changes unexpectedly. + private static final String PATH_CATALOG_ITEM_BY_ID = "/catalog-items/{id}"; + private static final String PATH_PROJECT_COMPONENT = "/project/{projectKey}/component/{componentId}"; + private static final String PATH_PROVISION_ACTIONS = "/provision-actions"; + private static final String PATH_DELETE_COMPONENT = "/support/delete/{projectKey}/{componentId}"; + private static final String PATH_NOTIFY_PROVISIONING = "/provision/{projectKey}/{status}"; + + @Mock + private MarketplaceApiClientFactory clientFactory; + + @Mock + private MarketplaceApiClient marketplaceApiClient; + + @Mock + private ApiClient apiClient; + + @Mock + private OboTokenService oboTokenService; + + private MarketplaceService marketplaceService; + + @BeforeEach + void setUp() { + marketplaceService = new MarketplaceServiceImpl(clientFactory, oboTokenService); + + // Set up a fake SecurityContext so JwtUtils.getTokenValue() works + Jwt jwt = Jwt.withTokenValue("test-jwt-assertion") + .header("alg", "RS256") + .claim("azp", "test-client-id") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(3600)) + .build(); + SecurityContext ctx = SecurityContextHolder.createEmptyContext(); + ctx.setAuthentication(new JwtAuthenticationToken(jwt)); + SecurityContextHolder.setContext(ctx); + + // Default OBO stub — tests that fail before OBO won't reach this + lenient().when(oboTokenService.exchangeToken(anyString(), anyString())) + .thenReturn("obo-test-token"); + + // 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); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + /** + * Stubs {@code apiClient.invokeAPI(...)} for a specific endpoint path and HTTP method. + * Using {@code eq(path)} and {@code eq(method)} (instead of bare {@code any()} for every + * argument) makes the test assert that the service is calling the expected generated-API + * operation, while still being tolerant of the surrounding boilerplate arguments. + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private OngoingStubbing> whenInvokeAPI(String path, HttpMethod method) { + return (OngoingStubbing) when(apiClient.invokeAPI( + eq(path), + eq(method), + any(), + any(), + any(), + any(HttpHeaders.class), + any(), + any(), + any(), + any(), + any(), + any(ParameterizedTypeReference.class))); + } + + // ------------------------------------------------------------------------- + // 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(); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROJECT_COMPONENT, HttpMethod.GET) + .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.setOboScope("api://test/scope"); + 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); + whenInvokeAPI(PATH_PROJECT_COMPONENT, HttpMethod.GET).thenThrow(notFoundEx); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + // Act + ProjectComponentExtendedInfo result = marketplaceService.getProjectComponent(instanceName, projectKey, componentId); + + // Assert + assertNull(result); + verify(clientFactory).getClient(instanceName); + } + + // ------------------------------------------------------------------------- + // isHealthy + // ------------------------------------------------------------------------- + + @Test + void testIsHealthy_NoInstancesConfigured_ReturnsFalse() { + when(clientFactory.getAvailableInstances()).thenReturn(Set.of()); + + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { + @Override + protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { + return true; + } + + @Override + protected boolean isCatalogEndpointUp(MarketplaceApiClient marketplaceClient) { + return true; + } + }; + + boolean result = service.isHealthy(); + + assertFalse(result); + } + + @Test + void testIsHealthy_BothEndpointsUp_ReturnsTrue() throws MarketplaceException { + when(clientFactory.getAvailableInstances()).thenReturn(Set.of("dev")); + when(clientFactory.getDefaultInstanceName()).thenReturn("dev"); + when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient); + + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { + @Override + protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { + return true; + } + + @Override + protected boolean isCatalogEndpointUp(MarketplaceApiClient marketplaceClient) { + return true; + } + }; + + boolean result = service.isHealthy(); + + assertTrue(result); + } + + @Test + void testIsHealthy_ProvisionerDown_ReturnsFalse() throws MarketplaceException { + when(clientFactory.getAvailableInstances()).thenReturn(Set.of("dev")); + when(clientFactory.getDefaultInstanceName()).thenReturn("dev"); + when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient); + + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { + @Override + protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { + return false; + } + }; + + boolean result = service.isHealthy(); + + assertFalse(result); + } + + @Test + void testIsHealthy_CatalogDown_ReturnsFalse() throws MarketplaceException { + when(clientFactory.getAvailableInstances()).thenReturn(Set.of("dev")); + when(clientFactory.getDefaultInstanceName()).thenReturn("dev"); + when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient); + + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { + @Override + protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { + return true; + } + + @Override + protected boolean isCatalogEndpointUp(MarketplaceApiClient marketplaceClient) { + return false; + } + }; + + boolean result = service.isHealthy(); + + 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.setOboScope("api://test/scope"); + + when(clientFactory.getDefaultInstanceName()).thenReturn("default"); + when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + whenInvokeAPI(PATH_PROJECT_COMPONENT, HttpMethod.GET).thenReturn(ResponseEntity.ok(null)); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + ProjectComponentExtendedInfo result = marketplaceService.getProjectComponent(projectKey, componentId); + + assertNull(result); + verify(clientFactory).getClient("default"); + } + + @Test + void testGetProjectComponent_NullInstanceName_PropagatesFactoryException() throws MarketplaceException { + // The factory rejects null/blank instance names; the service must surface that. + String projectKey = "PROJ"; + String componentId = "test-component"; + + when(clientFactory.getClient(null)) + .thenThrow(new MarketplaceException("Provide instance name. Available instances: []")); + + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.getProjectComponent(null, projectKey, componentId)); + + assertTrue(exception.getMessage().contains("Provide instance name")); + verify(clientFactory).getClient(null); + } + + @Test + void testGetProjectComponent_BlankInstanceName_PropagatesFactoryException() throws MarketplaceException { + // The factory rejects null/blank instance names; the service must surface that. + String projectKey = "PROJ"; + String componentId = "test-component"; + + when(clientFactory.getClient("")) + .thenThrow(new MarketplaceException("Provide instance name. Available instances: []")); + + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.getProjectComponent("", projectKey, componentId)); + + assertTrue(exception.getMessage().contains("Provide instance name")); + verify(clientFactory).getClient(""); + } + + @Test + void testGetProjectComponent_NoInstanceArg_NotFound_ReturnsFalse() throws MarketplaceException { + String projectKey = "ZZZNOPE"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + + HttpClientErrorException notFoundEx = HttpClientErrorException.create( + HttpStatus.NOT_FOUND, "Not Found", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getDefaultInstanceName()).thenReturn("default"); + when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + whenInvokeAPI(PATH_PROJECT_COMPONENT, HttpMethod.GET).thenThrow(notFoundEx); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + ProjectComponentExtendedInfo result = marketplaceService.getProjectComponent(projectKey, componentId); + + assertNull(result); + } + + @Test + void testGetProjectComponent_NoInstanceArg_RestClientException_ThrowsMarketplaceException() throws MarketplaceException { + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getDefaultInstanceName()).thenReturn("default"); + when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + whenInvokeAPI(PATH_PROJECT_COMPONENT, HttpMethod.GET).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"); + instanceConfig.setOboScope("api://test/scope"); + + 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); + whenInvokeAPI(PATH_PROVISION_ACTIONS, HttpMethod.POST).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()); + } + + @Test + void testGetCatalogItem_RestClientException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String catalogItemId = "test-catalog-item-base64-string"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_CATALOG_ITEM_BY_ID, HttpMethod.GET) + .thenThrow(new RestClientException("Connection failed")); + + // Act & Assert + assertThrows(MarketplaceException.class, () -> + marketplaceService.getCatalogItem(instanceName, catalogItemId)); + + verify(clientFactory).getClient(instanceName); + verify(marketplaceApiClient).getApiClient(); + } + + @Test + void testGetCatalogItem_NotFound_ReturnsNull() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String catalogItemId = "test-catalog-item-base64-string"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + 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); + whenInvokeAPI(PATH_CATALOG_ITEM_BY_ID, HttpMethod.GET).thenThrow(notFoundEx); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + // Act + CatalogItem result = marketplaceService.getCatalogItem(instanceName, catalogItemId); + + // Assert + assertNull(result); + verify(clientFactory).getClient(instanceName); + } + + @Test + void testGetCatalogItem_AuthError_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String catalogItemId = "test-catalog-item-base64-string"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException forbiddenEx = HttpClientErrorException.create( + HttpStatus.FORBIDDEN, "Forbidden", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + whenInvokeAPI(PATH_CATALOG_ITEM_BY_ID, HttpMethod.GET).thenThrow(forbiddenEx); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.getCatalogItem(instanceName, catalogItemId)); + + assertTrue(exception.getMessage().contains("Access denied")); + } + + @Test + void testGetCatalogItem_DefaultInstance() throws MarketplaceException { + // Arrange + String catalogItemId = "test-catalog-item-base64-string"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException notFoundEx = HttpClientErrorException.create( + HttpStatus.NOT_FOUND, "Not Found", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getDefaultInstanceName()).thenReturn("default"); + when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + whenInvokeAPI(PATH_CATALOG_ITEM_BY_ID, HttpMethod.GET).thenThrow(notFoundEx); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + // Act + CatalogItem result = marketplaceService.getCatalogItem(catalogItemId); + + // Assert + assertNull(result); + verify(clientFactory).getClient("default"); + } + + // ------------------------------------------------------------------------- + // getProjectComponent — auth error + // ------------------------------------------------------------------------- + + @Test + void testGetProjectComponent_AuthError_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException forbiddenEx = HttpClientErrorException.create( + HttpStatus.FORBIDDEN, "Forbidden", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROJECT_COMPONENT, HttpMethod.GET).thenThrow(forbiddenEx); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.getProjectComponent(instanceName, projectKey, componentId)); + + assertTrue(exception.getMessage().contains("Access denied")); + } + + // ------------------------------------------------------------------------- + // provisionProjectComponent — success, auth error, REST error, default instance + // ------------------------------------------------------------------------- + + @Test + void testProvisionProjectComponent_Success_ReturnsTrue() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + ProvisionActionResponse mockResponse = new ProvisionActionResponse(); + mockResponse.setFailed(false); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROVISION_ACTIONS, HttpMethod.POST) + .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK)); + + // Act + boolean result = marketplaceService.provisionProjectComponent(instanceName, projectKey, List.of()); + + // Assert + assertTrue(result); + verify(clientFactory).getClient(instanceName); + } + + @Test + void testProvisionProjectComponent_Failed_ReturnsFalse() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + ProvisionActionResponse mockResponse = new ProvisionActionResponse(); + mockResponse.setFailed(true); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROVISION_ACTIONS, HttpMethod.POST) + .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK)); + + // Act + boolean result = marketplaceService.provisionProjectComponent(instanceName, projectKey, List.of()); + + // Assert + assertFalse(result); + } + + @Test + void testProvisionProjectComponent_RestClientException_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROVISION_ACTIONS, HttpMethod.POST) + .thenThrow(new RestClientException("Connection refused")); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.provisionProjectComponent(instanceName, projectKey, List.of())); + + assertTrue(exception.getMessage().contains("Failed to provision")); + } + + @Test + void testProvisionProjectComponent_AuthError_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException unauthorizedEx = HttpClientErrorException.create( + HttpStatus.UNAUTHORIZED, "Unauthorized", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROVISION_ACTIONS, HttpMethod.POST).thenThrow(unauthorizedEx); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.provisionProjectComponent(instanceName, projectKey, List.of())); + + assertTrue(exception.getMessage().contains("Access denied")); + } + + @Test + void testProvisionProjectComponent_DefaultInstance_Success() throws MarketplaceException { + // Arrange + String projectKey = "PROJ"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + ProvisionActionResponse mockResponse = new ProvisionActionResponse(); + mockResponse.setFailed(false); + + when(clientFactory.getDefaultInstanceName()).thenReturn("default"); + when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROVISION_ACTIONS, HttpMethod.POST) + .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK)); + + // Act + boolean result = marketplaceService.provisionProjectComponent(projectKey, List.of()); + + // Assert + assertTrue(result); + verify(clientFactory).getClient("default"); + } + + @Test + void testProvisionProjectComponent_OboScopeNotConfigured_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + // OBO scope is null + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.provisionProjectComponent(instanceName, projectKey, List.of())); + + assertTrue(exception.getMessage().contains("OBO scope not configured")); + } + + // ------------------------------------------------------------------------- + // deleteProjectComponent + // ------------------------------------------------------------------------- + + @Test + void testDeleteProjectComponent_Success_ReturnsTrue() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + ProvisionActionResponse mockResponse = new ProvisionActionResponse(); + mockResponse.setFailed(false); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_DELETE_COMPONENT, HttpMethod.POST) + .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK)); + + // Act + boolean result = marketplaceService.deleteProjectComponent(instanceName, projectKey, componentId); + + // Assert + assertTrue(result); + verify(clientFactory).getClient(instanceName); + } + + @Test + void testDeleteProjectComponent_Failed_ReturnsFalse() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + ProvisionActionResponse mockResponse = new ProvisionActionResponse(); + mockResponse.setFailed(true); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_DELETE_COMPONENT, HttpMethod.POST) + .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK)); + + // Act + boolean result = marketplaceService.deleteProjectComponent(instanceName, projectKey, componentId); + + // Assert + assertFalse(result); + } + + @Test + void testDeleteProjectComponent_RestClientException_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_DELETE_COMPONENT, HttpMethod.POST) + .thenThrow(new RestClientException("Connection refused")); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.deleteProjectComponent(instanceName, projectKey, componentId)); + + assertTrue(exception.getMessage().contains("Failed to delete")); + } + + @Test + void testDeleteProjectComponent_AuthError_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException forbiddenEx = HttpClientErrorException.create( + HttpStatus.FORBIDDEN, "Forbidden", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_DELETE_COMPONENT, HttpMethod.POST).thenThrow(forbiddenEx); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.deleteProjectComponent(instanceName, projectKey, componentId)); + + assertTrue(exception.getMessage().contains("Access denied")); + } + + @Test + void testDeleteProjectComponent_DefaultInstance() throws MarketplaceException { + // Arrange + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + ProvisionActionResponse mockResponse = new ProvisionActionResponse(); + mockResponse.setFailed(false); + + when(clientFactory.getDefaultInstanceName()).thenReturn("default"); + when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_DELETE_COMPONENT, HttpMethod.POST) + .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK)); + + // Act + boolean result = marketplaceService.deleteProjectComponent(projectKey, componentId); + + // Assert + assertTrue(result); + verify(clientFactory).getClient("default"); + } + + // ------------------------------------------------------------------------- + // registerProjectComponent + // ------------------------------------------------------------------------- + + @Test + void testRegisterProjectComponent_Success() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_NOTIFY_PROVISIONING, HttpMethod.PUT).thenReturn(ResponseEntity.ok(null)); + + // Act — should not throw + marketplaceService.registerProjectComponent(instanceName, projectKey, componentId); + + // Assert + verify(clientFactory).getClient(instanceName); + } + + @Test + void testRegisterProjectComponent_RestClientException_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_NOTIFY_PROVISIONING, HttpMethod.PUT) + .thenThrow(new RestClientException("Connection refused")); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.registerProjectComponent(instanceName, projectKey, componentId)); + + assertTrue(exception.getMessage().contains("Failed to register")); + } + + @Test + void testRegisterProjectComponent_AuthError_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException unauthorizedEx = HttpClientErrorException.create( + HttpStatus.UNAUTHORIZED, "Unauthorized", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_NOTIFY_PROVISIONING, HttpMethod.PUT).thenThrow(unauthorizedEx); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.registerProjectComponent(instanceName, projectKey, componentId)); + + assertTrue(exception.getMessage().contains("Access denied")); + } + + @Test + void testRegisterProjectComponent_DefaultInstance() throws MarketplaceException { + // Arrange + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getDefaultInstanceName()).thenReturn("default"); + when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_NOTIFY_PROVISIONING, HttpMethod.PUT).thenReturn(ResponseEntity.ok(null)); + + // Act — should not throw + marketplaceService.registerProjectComponent(projectKey, componentId); + + // Assert + verify(clientFactory).getClient("default"); + } + +} \ No newline at end of file diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/PolicyDaoImpl.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/PolicyDaoImpl.java index 408d54b..9b85c97 100644 --- a/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/PolicyDaoImpl.java +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/PolicyDaoImpl.java @@ -41,7 +41,7 @@ public List findByApiDefinitionIdAndClientId(String apiDefinitionId, @Override public List findGlobalByApiDefinitionId(String apiDefinitionId) { - return repository.findByApiDefinitionIdAndClientIdIsNull(apiDefinitionId).stream() + return repository.findGlobalByApiDefinitionId(apiDefinitionId).stream() .map(this::toDto) .toList(); } diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/AuthorizationPolicyJpaRepository.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/AuthorizationPolicyJpaRepository.java index 8f71fb7..1df9182 100644 --- a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/AuthorizationPolicyJpaRepository.java +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/AuthorizationPolicyJpaRepository.java @@ -1,6 +1,8 @@ package org.opendevstack.apiservice.persistence.repository; import org.opendevstack.apiservice.persistence.entity.AuthorizationPolicyEntity; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; @@ -12,5 +14,11 @@ public interface AuthorizationPolicyJpaRepository extends JpaRepository findByApiDefinitionIdAndClientId(String apiDefinitionId, String clientId); - List findByApiDefinitionIdAndClientIdIsNull(String apiDefinitionId); + @Query(""" + SELECT p + FROM AuthorizationPolicyEntity p + WHERE p.apiDefinitionId = :apiDefinitionId + AND (p.clientId IS NULL OR TRIM(p.clientId) = '') + """) + List findGlobalByApiDefinitionId(@Param("apiDefinitionId") String apiDefinitionId); }