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
+
+ 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
+
+ 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);
}