diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..6313b56c
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text=auto eol=lf
diff --git a/.gitignore b/.gitignore
index 46a7edea..7b150c8c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -89,6 +89,8 @@ secring.gpg
*.yaml.dec
.scannerwork/
# Autogenerated API interface files
+api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/api
+api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model
api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/api
api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/model
api-project-platform/src/main/java/org/opendevstack/apiservice/projectplatform/api
diff --git a/Makefile b/Makefile
index ceea7c8b..34c1cb4a 100644
--- a/Makefile
+++ b/Makefile
@@ -3,7 +3,7 @@
# Project variables
PROJECT_NAME := opendevstack-api-service
-VERSION := 0.0.3
+VERSION := 0.0.4
JAVA_VERSION := 21
MAIN_CLASS := org.opendevstack.apiservice.core.DevstackApiServiceApplication
diff --git a/README.md b/README.md
index bfda2162..7f851cb1 100644
--- a/README.md
+++ b/README.md
@@ -55,7 +55,7 @@ Build a traditional Spring Boot JAR file:
```bash
make jar
```
-- Output: `core/target/core-0.0.3.jar`
+- Output: `core/target/core-0.0.4.jar`
- Includes all dependencies
- Standard Spring Boot startup time
diff --git a/api-marketplace-metrics/README.md b/api-marketplace-metrics/README.md
new file mode 100644
index 00000000..a2f07479
--- /dev/null
+++ b/api-marketplace-metrics/README.md
@@ -0,0 +1,76 @@
+# Marketplace Metrics API Module
+
+This module provides REST API endpoint to obtain metrics from the Marketplace.
+
+## Integration with Core Application
+
+To integrate these modules with your core application:
+
+1. **Add module dependencies** to your core `pom.xml`:
+
+```xml
+
+
+ org.opendevstack.apiservice
+ api-marketplace-metrics
+ ${project.version}
+
+
+```
+## Error Handling
+
+The API provides comprehensive error handling with structured error responses:
+
+- **400 Bad Request**: Invalid request data
+- **404 Not Found**: Resource not found (project, user)
+- **409 Conflict**: Resource already exists
+- **500 Internal Server Error**: System errors, automation platform failures
+
+## Monitoring and Observability
+
+The modules provide comprehensive logging and can be monitored through:
+
+1. **Application Logs**: Structured logging for all operations
+2. **Metrics**: Integration with Spring Boot Actuator
+3. **Health Checks**: Automation platform connectivity checks
+4. **Distributed Tracing**: Support for distributed tracing systems with Opentelemetry and Dynatrace
+
+## Testing
+
+Both modules include comprehensive test suites:
+
+- **Unit Tests**: Business logic validation
+- **Integration Tests**: API endpoint testing
+- **Mock Tests**: Automation platform interaction testing
+
+Run tests with:
+
+```bash
+./mvnw test
+```
+
+## Development
+
+### Building the Modules
+
+```bash
+# Build all modules
+./mvnw clean install
+
+# Build specific module
+./mvnw clean install -pl api-project-users
+./mvnw clean install -pl api-marketplace-metrics
+./mvnw clean install -pl external-service-app
+```
+
+### Running Locally
+
+```bash
+# Run with dev profile
+./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
+```
+
+### API Documentation
+
+Swagger UI is available at: `http://localhost:8080/swagger-ui.html`
+
diff --git a/api-marketplace-metrics/openapi/api-marketplace-metrics.yaml b/api-marketplace-metrics/openapi/api-marketplace-metrics.yaml
new file mode 100644
index 00000000..7bf3d5e2
--- /dev/null
+++ b/api-marketplace-metrics/openapi/api-marketplace-metrics.yaml
@@ -0,0 +1,308 @@
+openapi: 3.0.3
+info:
+ title: ODS API Server
+ description: API documentation for ODS (Open DevStack) API Service
+ contact:
+ name: ODS Team
+ version: v0.0.1
+servers:
+ - url: http://{baseurl}/api/v1
+ variables:
+ baseurl:
+ default: localhost:8080
+ description: Development environment
+tags:
+ - name: Marketplace metrics
+ description: API for managing marketplace metrics
+paths:
+ /metrics/marketplace/project-components:
+ get:
+ tags:
+ - MarketplaceMetrics
+ summary: Get Marketplace metrics regarding project components.
+ description: Returns all the components that were provisioned from the Marketplace in all the projects.
+ operationId: getMarketplaceProjectComponentsMetrics
+ parameters:
+ - name: page
+ in: query
+ description: Page number (from 0 until limit)
+ required: false
+ schema:
+ type: integer
+ default: 0
+ - name: size
+ in: query
+ description: Page size
+ required: false
+ schema:
+ type: integer
+ default: 20
+ responses:
+ "200":
+ description: A paginated list of all the provisioned project components
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/MarketplaceProjectComponentsMetrics"
+ "401":
+ $ref: "#/components/responses/UnauthorizedResponse"
+ "403":
+ $ref: "#/components/responses/ForbiddenResponse"
+ "404":
+ $ref: "#/components/responses/NotFoundResponse"
+ "500":
+ $ref: "#/components/responses/InternalErrorResponse"
+
+ /metrics/marketplace/catalog-items:
+ get:
+ tags:
+ - MarketplaceMetrics
+ summary: Get Marketplace metrics regarding catalog items.
+ description: Returns usage metrics of catalog items available in the marketplace.
+ operationId: getMarketplaceCatalogItemsMetrics
+ responses:
+ "200":
+ description: A paginated list of catalog items metrics
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/MarketplaceCatalogItemsMetrics"
+ "401":
+ $ref: "#/components/responses/UnauthorizedResponse"
+ "403":
+ $ref: "#/components/responses/ForbiddenResponse"
+ "404":
+ $ref: "#/components/responses/NotFoundResponse"
+ "500":
+ $ref: "#/components/responses/InternalErrorResponse"
+
+components:
+ responses:
+ UnauthorizedResponse:
+ description: Invalid client token
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RestErrorMessage'
+ ForbiddenResponse:
+ description: Insufficient permissions
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RestErrorMessage'
+ NotFoundResponse:
+ description: Resource not found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RestErrorMessage'
+ InternalErrorResponse:
+ description: Server error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RestErrorMessage'
+
+ schemas:
+ RestErrorMessage:
+ type: object
+ properties:
+ message:
+ type: string
+ required:
+ - message
+
+ MarketplaceProjectComponentsMetrics:
+ type: object
+ example:
+ data:
+ - projectKey: SOMEPROJECT
+ componentId: any-component-id-from-backend
+ caller: some-person@email.com
+ catalogItemSlug: some_technology-name
+ createdAt: 1707043200000
+ updatedAt: 1707043200000
+ - projectKey: ANOTHERPROJECT
+ componentId: another-component-id-from-backend
+ caller: some-person2@email.com
+ catalogItemSlug: another_technology-name
+ createdAt: 1707043200001
+ updatedAt: 1707043200002
+ pagination:
+ page: 0
+ size: 20
+ totalElements: 117
+ totalPages: 6
+ next: https://api.example.com/resources?page=1&size=20
+ previous: null
+ properties:
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/MarketplaceProjectComponentMetrics'
+ pagination:
+ $ref: '#/components/schemas/MarketplaceProjectComponentsMetricsPagination'
+ MarketplaceProjectComponentMetrics:
+ type: object
+ example:
+ projectKey: SOMEPROJECT
+ componentId: any-component-id-from-backend
+ caller: some-person@email.com
+ catalogItemSlug: some_technology-name
+ createdAt: 1707043200000
+ updatedAt: 1707043200000
+ properties:
+ projectKey:
+ description: The projectKey which the component is provisioned for.
+ example: SOMEPROJECT
+ type: string
+ componentId:
+ description: The componentId set by the user.
+ example: any-component-id-from-backend
+ minLength: 1
+ pattern: ^(?!\s*$).+
+ type: string
+ caller:
+ description: The email of who provisioned the component.
+ example: some-person@email.com
+ type: string
+ catalogItemSlug:
+ description: The provisioned catalog item slug.
+ example: some_technology-name
+ type: string
+ createdAt:
+ description: The timestamp of the provision action.
+ example: 1707043200000
+ type: number
+ updatedAt:
+ description: The timestamp of the last change of the provisioned component.
+ example: 1707043200000
+ type: number
+ MarketplaceProjectComponentsMetricsPagination:
+ type: object
+ example:
+ page: 0
+ size: 20
+ totalElements: 117
+ totalPages: 6
+ next: "https://api.example.com/resources?page=1&size=20"
+ previous: null
+ properties:
+ page:
+ type: integer
+ example: 0
+ description: Current page of the response.
+ size:
+ type: integer
+ example: 20
+ description: Current page size of the response.
+ totalElements:
+ type: integer
+ format: integer
+ example: 117
+ description: Total number of elements.
+ totalPages:
+ type: integer
+ example: 6
+ description: Total number of pages of this exact size.
+ next:
+ type: string
+ format: uri
+ nullable: true
+ example: "https://api.example.com/resources?page=1&size=20"
+ description: URL of the next page (or null if the current is the last one)
+ previous:
+ type: string
+ format: uri
+ nullable: true
+ example: "https://api.example.com/resources?page=0&size=20"
+ description: URL of the previous page (or null if the current is the first one)
+
+ MarketplaceCatalogItemsMetrics:
+ type: object
+ example:
+ data:
+ - id: aSdFam...yCg==
+ slug: myproject_my-component-repo
+ title: An item title
+ shortDescription: Example description
+ date: "2021-07-01T00:00:00Z"
+ pagination:
+ page: 0
+ size: 20
+ totalElements: 50
+ totalPages: 3
+ next: https://api.example.com/resources?page=1&size=20
+ previous: null
+ properties:
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/CatalogItem'
+ pagination:
+ $ref: '#/components/schemas/MarketplaceCatalogItemsMetricsPagination'
+
+ MarketplaceCatalogItemsMetricsPagination:
+ type: object
+ properties:
+ page:
+ type: integer
+ size:
+ type: integer
+ totalElements:
+ type: integer
+ totalPages:
+ type: integer
+ next:
+ type: string
+ format: uri
+ nullable: true
+ previous:
+ type: string
+ format: uri
+ nullable: true
+
+ CatalogItem:
+ type: object
+ properties:
+ id:
+ type: string
+ slug:
+ type: string
+ path:
+ type: string
+ title:
+ type: string
+ shortDescription:
+ type: string
+ descriptionFileId:
+ type: string
+ imageFileId:
+ type: string
+ description:
+ type: string
+ description: Resolved description content of the catalog item (e.g. markdown converted to plain text or HTML)
+ example: "This component provides a reusable service for handling..."
+ itemSrc:
+ type: string
+ tags:
+ type: array
+ items:
+ $ref: '#/components/schemas/CatalogItemTag'
+ authors:
+ type: array
+ items:
+ type: string
+ date:
+ type: string
+ format: date-time
+
+ CatalogItemTag:
+ type: object
+ properties:
+ label:
+ type: string
+ options:
+ type: array
+ items:
+ type: string
\ No newline at end of file
diff --git a/api-marketplace-metrics/pom.xml b/api-marketplace-metrics/pom.xml
new file mode 100644
index 00000000..9e29b826
--- /dev/null
+++ b/api-marketplace-metrics/pom.xml
@@ -0,0 +1,187 @@
+
+ 4.0.0
+
+
+ org.opendevstack.apiservice
+ devstack-api-service
+ 0.0.4
+
+
+ api-marketplace-metrics
+ API Marketplace Metrics
+ API module for managing Marketplace metrics
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+ org.springframework.boot
+ spring-boot-starter-oauth2-resource-server
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+
+
+
+
+ org.opendevstack.apiservice
+ external-service-marketplace
+ ${project.version}
+
+
+
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.12.3
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.12.3
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.12.3
+ runtime
+
+
+
+ org.projectlombok
+ lombok
+ provided
+
+
+
+
+ org.openapitools
+ jackson-databind-nullable
+ ${jackson-databind-nullable.version}
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.security
+ spring-security-core
+
+
+
+
+
+
+ org.openapitools
+ openapi-generator-maven-plugin
+
+
+
+ generate-api-marketplace-metrics
+ generate-sources
+
+
+ generate
+
+
+
+ spring
+
+
+
+ spring-boot
+
+
+ ${project.basedir}/openapi/api-marketplace-metrics.yaml
+
+
+
+ org.opendevstack.apiservice.marketplacemetrics.api
+
+
+
+ org.opendevstack.apiservice.marketplacemetrics.model
+
+
+
+ org.opendevstack.apiservice.marketplacemetrics
+
+
+ false
+ false
+ false
+ false
+ false
+
+
+ true
+ true
+ springdoc
+ true
+ true
+ true
+
+
+
+
+
+
+ org.codehaus.mojo
+ build-helper-maven-plugin
+ 3.6.0
+
+
+
+ add-openapi-sources
+ generate-sources
+
+
+ add-source
+
+
+
+
+
+ ${project.build.directory}/generated-sources/openapi/src/main/java
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+ true
+
+
+
+
+
diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/controller/MarketplaceMetricsController.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/controller/MarketplaceMetricsController.java
new file mode 100644
index 00000000..822c6852
--- /dev/null
+++ b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/controller/MarketplaceMetricsController.java
@@ -0,0 +1,59 @@
+package org.opendevstack.apiservice.marketplacemetrics.controller;
+
+import lombok.AllArgsConstructor;
+import org.opendevstack.apiservice.marketplacemetrics.api.MarketplaceMetricsApi;
+import org.opendevstack.apiservice.marketplacemetrics.exception.MarketplaceMetricsException;
+import org.opendevstack.apiservice.marketplacemetrics.facade.MarketplaceMetricsFacade;
+import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceCatalogItemsMetrics;
+import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/v1/projects")
+@AllArgsConstructor
+public class MarketplaceMetricsController implements MarketplaceMetricsApi {
+
+ MarketplaceMetricsFacade marketplaceMetricsFacade;
+
+ @CrossOrigin(origins = "*")
+ @GetMapping("/metrics/marketplace/catalog-items")
+ @Override
+ public ResponseEntity getMarketplaceCatalogItemsMetrics() {
+ MarketplaceCatalogItemsMetrics catalogItemsMetrics;
+ try {
+ catalogItemsMetrics = marketplaceMetricsFacade.getMarketplaceCatalogItemsMetrics();
+ } catch (MarketplaceMetricsException e) {
+ return sneakyThrow(e);
+ }
+
+ if (catalogItemsMetrics == null) {
+ return ResponseEntity.notFound().build();
+ } else {
+ return ResponseEntity.ok(catalogItemsMetrics);
+ }
+ }
+
+ @Override
+ @CrossOrigin(origins = "*")
+ @GetMapping("/metrics/marketplace/project-components")
+ public ResponseEntity getMarketplaceProjectComponentsMetrics(Integer page, Integer size) {
+ MarketplaceProjectComponentsMetrics marketplaceProjectComponentsMetrics;
+ try {
+ marketplaceProjectComponentsMetrics = marketplaceMetricsFacade.getMarketplaceProjectComponentsMetrics(page, size);
+ } catch (MarketplaceMetricsException e) {
+ return sneakyThrow(e);
+ }
+
+ if (marketplaceProjectComponentsMetrics == null) {
+ return ResponseEntity.notFound().build();
+ } else {
+ return ResponseEntity.ok(marketplaceProjectComponentsMetrics);
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static R sneakyThrow(Throwable e) throws E {
+ throw (E) e;
+ }
+}
diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/exception/MarketplaceMetricsException.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/exception/MarketplaceMetricsException.java
new file mode 100644
index 00000000..1cae255d
--- /dev/null
+++ b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/exception/MarketplaceMetricsException.java
@@ -0,0 +1,15 @@
+package org.opendevstack.apiservice.marketplacemetrics.exception;
+
+/**
+ * Exception thrown when there are issues with projects info service operations.
+ */
+public class MarketplaceMetricsException extends Exception {
+
+ public MarketplaceMetricsException(String message) {
+ super(message);
+ }
+
+ public MarketplaceMetricsException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/MarketplaceMetricsFacade.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/MarketplaceMetricsFacade.java
new file mode 100644
index 00000000..60253f43
--- /dev/null
+++ b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/MarketplaceMetricsFacade.java
@@ -0,0 +1,11 @@
+package org.opendevstack.apiservice.marketplacemetrics.facade;
+
+import org.opendevstack.apiservice.marketplacemetrics.exception.MarketplaceMetricsException;
+import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceCatalogItemsMetrics;
+import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics;
+
+public interface MarketplaceMetricsFacade {
+ MarketplaceProjectComponentsMetrics getMarketplaceProjectComponentsMetrics(Integer page, Integer size) throws MarketplaceMetricsException;
+
+ MarketplaceCatalogItemsMetrics getMarketplaceCatalogItemsMetrics() throws MarketplaceMetricsException;
+}
diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImpl.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImpl.java
new file mode 100644
index 00000000..4cb3c919
--- /dev/null
+++ b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImpl.java
@@ -0,0 +1,46 @@
+package org.opendevstack.apiservice.marketplacemetrics.facade.impl;
+
+import lombok.extern.slf4j.Slf4j;
+import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException;
+import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService;
+import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListResponse;
+import org.opendevstack.apiservice.marketplacemetrics.exception.MarketplaceMetricsException;
+import org.opendevstack.apiservice.marketplacemetrics.facade.MarketplaceMetricsFacade;
+import org.opendevstack.apiservice.marketplacemetrics.mapper.MarketplaceMetricsMapper;
+import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceCatalogItemsMetrics;
+import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics;
+import org.springframework.stereotype.Component;
+
+@Component
+@Slf4j
+public class MarketplaceMetricsFacadeImpl implements MarketplaceMetricsFacade {
+
+ private final MarketplaceService marketplaceService;
+ private final MarketplaceMetricsMapper mapper;
+
+ public MarketplaceMetricsFacadeImpl(MarketplaceService marketplaceService, MarketplaceMetricsMapper mapper) {
+ this.marketplaceService = marketplaceService;
+ this.mapper = mapper;
+ }
+
+ @Override
+ public MarketplaceProjectComponentsMetrics getMarketplaceProjectComponentsMetrics(Integer page, Integer size) throws MarketplaceMetricsException {
+ try {
+ ProjectComponentListResponse allProjectComponents =
+ marketplaceService.getAllProjectComponents(page, size);
+ return mapper.toApiModel(allProjectComponents);
+ } catch (MarketplaceException e) {
+ throw new MarketplaceMetricsException("Failed to retrieve marketplace project components metrics.", e);
+ }
+ }
+
+ @Override
+ public MarketplaceCatalogItemsMetrics getMarketplaceCatalogItemsMetrics() throws MarketplaceMetricsException {
+ try {
+ var allCatalogItems = marketplaceService.getAllCatalogItems();
+ return mapper.toApiModel(allCatalogItems);
+ } catch (MarketplaceException e) {
+ throw new MarketplaceMetricsException("Failed to retrieve marketplace catalog items metrics.", e);
+ }
+ }
+}
diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapper.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapper.java
new file mode 100644
index 00000000..d12f5790
--- /dev/null
+++ b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapper.java
@@ -0,0 +1,148 @@
+package org.opendevstack.apiservice.marketplacemetrics.mapper;
+
+import org.openapitools.jackson.nullable.JsonNullable;
+import org.opendevstack.apiservice.externalservice.marketplace.client.model.Pagination;
+import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListItem;
+import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListResponse;
+import org.opendevstack.apiservice.marketplacemetrics.model.CatalogItem;
+import org.opendevstack.apiservice.marketplacemetrics.model.CatalogItemTag;
+import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceCatalogItemsMetrics;
+import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentMetrics;
+import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics;
+import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetricsPagination;
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Mapper class for converting between external service and API models.
+ */
+@Component
+public class MarketplaceMetricsMapper {
+
+ /**
+ * Converts external service ProjectComponentListResponse to API MarketplaceProjectComponentsMetrics.
+ *
+ * @param projectComponentListResponse the external service project components metrics
+ * @return the API MarketplaceProjectComponentsMetrics
+ */
+ public MarketplaceProjectComponentsMetrics toApiModel(ProjectComponentListResponse projectComponentListResponse) {
+
+ if (projectComponentListResponse == null) {
+ return null;
+ }
+
+ MarketplaceProjectComponentsMetrics marketplaceProjectComponentsMetrics = new MarketplaceProjectComponentsMetrics();
+
+ if (projectComponentListResponse.getData() != null) {
+ marketplaceProjectComponentsMetrics.setData(
+ projectComponentListResponse.getData().stream()
+ .map(this::toApiMarketplaceProjectComponentMetric)
+ .toList()
+ );
+ }
+
+ marketplaceProjectComponentsMetrics.setPagination(this.toApiMarketplaceProjectComponentsMetricsPagination(projectComponentListResponse.getPagination()));
+
+ return marketplaceProjectComponentsMetrics;
+ }
+
+ /**
+ * Converts external service ProjectComponentListItem to API MarketplaceProjectComponentMetrics.
+ *
+ * @param projectComponentListItem the external service ProjectComponentListItem
+ * @return the API MarketplaceProjectComponentMetrics
+ */
+ private MarketplaceProjectComponentMetrics toApiMarketplaceProjectComponentMetric(ProjectComponentListItem projectComponentListItem) {
+
+ if (projectComponentListItem == null) {
+ return null;
+ }
+
+ MarketplaceProjectComponentMetrics marketplaceProjectComponentMetrics = new MarketplaceProjectComponentMetrics();
+ marketplaceProjectComponentMetrics.setComponentId(projectComponentListItem.getComponentId());
+ marketplaceProjectComponentMetrics.setProjectKey(projectComponentListItem.getProjectKey());
+ marketplaceProjectComponentMetrics.setCaller(projectComponentListItem.getCaller());
+ marketplaceProjectComponentMetrics.setCreatedAt(projectComponentListItem.getCreatedAt());
+ marketplaceProjectComponentMetrics.setUpdatedAt(projectComponentListItem.getUpdatedAt());
+ marketplaceProjectComponentMetrics.setCatalogItemSlug(projectComponentListItem.getCatalogItemSlug());
+
+ return marketplaceProjectComponentMetrics;
+ }
+
+ /**
+ * Converts external service Pagination to API MarketplaceProjectComponentsMetricsPagination.
+ *
+ * @param pagination the external service Pagination
+ * @return the API MarketplaceProjectComponentsMetricsPagination
+ */
+ private MarketplaceProjectComponentsMetricsPagination toApiMarketplaceProjectComponentsMetricsPagination(Pagination pagination) {
+
+ if (pagination == null) {
+ return null;
+ }
+
+ MarketplaceProjectComponentsMetricsPagination marketplaceProjectComponentsMetricsPagination = new MarketplaceProjectComponentsMetricsPagination();
+ marketplaceProjectComponentsMetricsPagination.setNext(JsonNullable.of(pagination.getNext()));
+ marketplaceProjectComponentsMetricsPagination.setPage(pagination.getPage());
+ marketplaceProjectComponentsMetricsPagination.setPrevious(JsonNullable.of(pagination.getPrevious()));
+ marketplaceProjectComponentsMetricsPagination.setSize(pagination.getSize());
+ marketplaceProjectComponentsMetricsPagination.setTotalPages(pagination.getTotalPages());
+ marketplaceProjectComponentsMetricsPagination.setTotalElements(pagination.getTotalElements());
+
+ return marketplaceProjectComponentsMetricsPagination;
+ }
+
+ public MarketplaceCatalogItemsMetrics toApiModel(List allCatalogItems) {
+ var marketplaceCatalogItemsMetrics = new MarketplaceCatalogItemsMetrics();
+
+ var catalogItems = allCatalogItems.stream()
+ .map(this::toApiModel)
+ .toList();
+
+ marketplaceCatalogItemsMetrics.setData(catalogItems);
+
+ return marketplaceCatalogItemsMetrics;
+ }
+
+ public CatalogItem toApiModel(org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem catalogItem) {
+
+ var tags = Optional.ofNullable(catalogItem.getTags()).orElse(Collections.emptyList())
+ .stream()
+ .map(this::toApiModel)
+ .toList();
+
+ var apiCatalogItem = new CatalogItem();
+
+ apiCatalogItem.setId(catalogItem.getId());
+ apiCatalogItem.setSlug(catalogItem.getSlug());
+ apiCatalogItem.setPath(catalogItem.getPath());
+ apiCatalogItem.setTitle(catalogItem.getTitle());
+ apiCatalogItem.setShortDescription(catalogItem.getShortDescription());
+ apiCatalogItem.setDescriptionFileId(catalogItem.getDescriptionFileId());
+ apiCatalogItem.setImageFileId(catalogItem.getImageFileId());
+ apiCatalogItem.setDescription(catalogItem.getDescription());
+ apiCatalogItem.setItemSrc(catalogItem.getItemSrc());
+ apiCatalogItem.setTags(tags);
+ apiCatalogItem.setAuthors(catalogItem.getAuthors());
+ apiCatalogItem.setDate(catalogItem.getDate());
+
+ return apiCatalogItem;
+ }
+
+ public CatalogItemTag toApiModel(org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag catalogItemTag) {
+ var options = Optional.ofNullable(catalogItemTag.getOptions()).orElse(Collections.emptySet())
+ .stream()
+ .toList();
+
+ var apiCatalogItemTag = new CatalogItemTag();
+
+ apiCatalogItemTag.setLabel(catalogItemTag.getLabel());
+ apiCatalogItemTag.setOptions(options);
+
+ return apiCatalogItemTag;
+ }
+}
+
diff --git a/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/controller/MarketplaceMetricsControllerTest.java b/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/controller/MarketplaceMetricsControllerTest.java
new file mode 100644
index 00000000..782d3bd5
--- /dev/null
+++ b/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/controller/MarketplaceMetricsControllerTest.java
@@ -0,0 +1,112 @@
+package org.opendevstack.apiservice.marketplacemetrics.controller;
+
+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.marketplacemetrics.exception.MarketplaceMetricsException;
+import org.opendevstack.apiservice.marketplacemetrics.facade.MarketplaceMetricsFacade;
+import org.opendevstack.apiservice.marketplacemetrics.model.CatalogItem;
+import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceCatalogItemsMetrics;
+import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentMetrics;
+import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics;
+import org.springframework.http.ResponseEntity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class MarketplaceMetricsControllerTest {
+
+ @Mock
+ MarketplaceMetricsFacade marketplaceMetricsFacade;
+
+ MarketplaceMetricsController controller;
+
+ @BeforeEach
+ void setUp() {
+ controller = new MarketplaceMetricsController(marketplaceMetricsFacade);
+ }
+
+ @Test
+ void GivenFacadeReturnsCatalogMetrics_whenGetMarketplaceCatalogItemsMetrics_ThenReturnOkWithBody() throws MarketplaceMetricsException {
+ //given
+ MarketplaceCatalogItemsMetrics metrics = new MarketplaceCatalogItemsMetrics()
+ .addDataItem(new CatalogItem().id("id1").title("title1"));
+ when(marketplaceMetricsFacade.getMarketplaceCatalogItemsMetrics()).thenReturn(metrics);
+
+ //when
+ ResponseEntity response = controller.getMarketplaceCatalogItemsMetrics();
+
+ //then
+ assertThat(response.getStatusCode().value()).isEqualTo(200);
+ assertThat(response.getBody()).isEqualTo(metrics);
+ }
+
+ @Test
+ void GivenFacadeReturnsNull_whenGetMarketplaceCatalogItemsMetrics_ThenReturnNotFound() throws MarketplaceMetricsException {
+ //given
+ when(marketplaceMetricsFacade.getMarketplaceCatalogItemsMetrics()).thenReturn(null);
+
+ //when
+ ResponseEntity response = controller.getMarketplaceCatalogItemsMetrics();
+
+ //then
+ assertThat(response.getStatusCode().value()).isEqualTo(404);
+ assertThat(response.getBody()).isNull();
+ }
+
+ @Test
+ void GivenFacadeThrowsException_whenGetMarketplaceCatalogItemsMetrics_ThenThrowMarketplaceMetricsException() throws MarketplaceMetricsException {
+ //given
+ when(marketplaceMetricsFacade.getMarketplaceCatalogItemsMetrics()).thenThrow(new MarketplaceMetricsException("boom"));
+
+ //when //then
+ assertThatThrownBy(() -> controller.getMarketplaceCatalogItemsMetrics())
+ .isInstanceOf(MarketplaceMetricsException.class)
+ .hasMessageContaining("boom");
+ }
+
+ @Test
+ void GivenFacadeReturnsProjectComponentsMetrics_whenGetMarketplaceProjectComponentsMetrics_ThenReturnOkWithBody() throws MarketplaceMetricsException {
+ //given
+ MarketplaceProjectComponentsMetrics metrics = new MarketplaceProjectComponentsMetrics()
+ .addDataItem(new MarketplaceProjectComponentMetrics().projectKey("P1").componentId("C1"));
+ when(marketplaceMetricsFacade.getMarketplaceProjectComponentsMetrics(1, 10)).thenReturn(metrics);
+
+ //when
+ ResponseEntity response = controller.getMarketplaceProjectComponentsMetrics(1, 10);
+
+ //then
+ assertThat(response.getStatusCode().value()).isEqualTo(200);
+ assertThat(response.getBody()).isEqualTo(metrics);
+ }
+
+ @Test
+ void GivenFacadeReturnsNull_whenGetMarketplaceProjectComponentsMetrics_ThenReturnNotFound() throws MarketplaceMetricsException {
+ //given
+ when(marketplaceMetricsFacade.getMarketplaceProjectComponentsMetrics(0, 0)).thenReturn(null);
+
+ //when
+ ResponseEntity response = controller.getMarketplaceProjectComponentsMetrics(0, 0);
+
+ //then
+ assertThat(response.getStatusCode().value()).isEqualTo(404);
+ assertThat(response.getBody()).isNull();
+ }
+
+ @Test
+ void GivenFacadeThrowsException_whenGetMarketplaceProjectComponentsMetrics_ThenThrowMarketplaceMetricsException() throws MarketplaceMetricsException {
+ //given
+ when(marketplaceMetricsFacade.getMarketplaceProjectComponentsMetrics(null, null)).thenThrow(new MarketplaceMetricsException("err"));
+
+ //when //then
+ assertThatThrownBy(() -> controller.getMarketplaceProjectComponentsMetrics(null, null))
+ .isInstanceOf(MarketplaceMetricsException.class)
+ .hasMessageContaining("err");
+ }
+}
+
+
diff --git a/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImplTest.java b/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImplTest.java
new file mode 100644
index 00000000..4d3fa457
--- /dev/null
+++ b/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImplTest.java
@@ -0,0 +1,114 @@
+package org.opendevstack.apiservice.marketplacemetrics.facade.impl;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListResponse;
+import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem;
+import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException;
+import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService;
+import org.opendevstack.apiservice.marketplacemetrics.exception.MarketplaceMetricsException;
+import org.opendevstack.apiservice.marketplacemetrics.mapper.MarketplaceMetricsMapper;
+import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics;
+import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceCatalogItemsMetrics;
+import java.util.List;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+class MarketplaceMetricsFacadeImplTest {
+
+ private MarketplaceService marketplaceService;
+ private MarketplaceMetricsMapper mapper;
+
+ private MarketplaceMetricsFacadeImpl sut;
+
+ @BeforeEach
+ void setup() {
+ marketplaceService = mock(MarketplaceService.class);
+ mapper = mock(MarketplaceMetricsMapper.class);
+
+ sut = new MarketplaceMetricsFacadeImpl(marketplaceService, mapper);
+
+ // Reset security context before each test
+ SecurityContextHolder.clearContext();
+ }
+
+ @AfterEach
+ void cleanup() {
+ SecurityContextHolder.clearContext();
+ }
+
+ @Test
+ void get_marketplace_project_components_metrics_returns_mapped_result_when_service_succeeds() throws Exception {
+ Integer page = 1;
+ Integer size = 25;
+ ProjectComponentListResponse serviceResponse = mock(ProjectComponentListResponse.class);
+ MarketplaceProjectComponentsMetrics mappedResponse = mock(MarketplaceProjectComponentsMetrics.class);
+
+ when(marketplaceService.getAllProjectComponents(page, size)).thenReturn(serviceResponse);
+ when(mapper.toApiModel(serviceResponse)).thenReturn(mappedResponse);
+
+ MarketplaceProjectComponentsMetrics result = sut.getMarketplaceProjectComponentsMetrics(page, size);
+
+ assertThat(result).isSameAs(mappedResponse);
+ verify(marketplaceService).getAllProjectComponents(page, size);
+ verify(mapper).toApiModel(serviceResponse);
+ }
+
+ @Test
+ void get_marketplace_project_components_metrics_wraps_marketplace_exception() throws Exception {
+ Integer page = 2;
+ Integer size = 10;
+ MarketplaceException cause = new MarketplaceException("marketplace unavailable");
+
+ when(marketplaceService.getAllProjectComponents(page, size)).thenThrow(cause);
+
+ assertThatThrownBy(() -> sut.getMarketplaceProjectComponentsMetrics(page, size))
+ .isInstanceOf(MarketplaceMetricsException.class)
+ .hasMessage("Failed to retrieve marketplace project components metrics.")
+ .hasCause(cause);
+
+ verify(marketplaceService).getAllProjectComponents(page, size);
+ verifyNoInteractions(mapper);
+ }
+
+ @Test
+ void GivenServiceReturnsCatalogItems_whenGetMarketplaceCatalogItemsMetrics_ThenReturnMappedResult() throws Exception {
+ //given
+ List serviceResponse = List.of(mock(CatalogItem.class));
+ MarketplaceCatalogItemsMetrics mappedResponse = mock(MarketplaceCatalogItemsMetrics.class);
+ when(marketplaceService.getAllCatalogItems()).thenReturn(serviceResponse);
+ when(mapper.toApiModel(serviceResponse)).thenReturn(mappedResponse);
+
+ //when
+ MarketplaceCatalogItemsMetrics result = sut.getMarketplaceCatalogItemsMetrics();
+
+ //then
+ assertThat(result).isSameAs(mappedResponse);
+ verify(marketplaceService).getAllCatalogItems();
+ verify(mapper).toApiModel(serviceResponse);
+ }
+
+ @Test
+ void GivenServiceThrowsMarketplaceException_whenGetMarketplaceCatalogItemsMetrics_ThenWrapMarketplaceMetricsException() throws Exception {
+ //given
+ MarketplaceException cause = new MarketplaceException("unavailable");
+ when(marketplaceService.getAllCatalogItems()).thenThrow(cause);
+
+ //when //then
+ assertThatThrownBy(() -> sut.getMarketplaceCatalogItemsMetrics())
+ .isInstanceOf(MarketplaceMetricsException.class)
+ .hasMessage("Failed to retrieve marketplace catalog items metrics.")
+ .hasCause(cause);
+
+ verify(marketplaceService).getAllCatalogItems();
+ verifyNoInteractions(mapper);
+ }
+
+}
\ No newline at end of file
diff --git a/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapperTest.java b/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapperTest.java
new file mode 100644
index 00000000..7d04e090
--- /dev/null
+++ b/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapperTest.java
@@ -0,0 +1,280 @@
+package org.opendevstack.apiservice.marketplacemetrics.mapper;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.opendevstack.apiservice.externalservice.marketplace.client.model.Pagination;
+import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListItem;
+import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListResponse;
+import org.opendevstack.apiservice.marketplacemetrics.model.CatalogItem;
+import org.opendevstack.apiservice.marketplacemetrics.model.CatalogItemTag;
+import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceCatalogItemsMetrics;
+import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentMetrics;
+import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics;
+
+import java.math.BigDecimal;
+import java.net.URI;
+import java.time.OffsetDateTime;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class MarketplaceMetricsMapperTest {
+
+ private MarketplaceMetricsMapper mapper;
+
+ @BeforeEach
+ void setUp() {
+ mapper = new MarketplaceMetricsMapper();
+ }
+
+ @Test
+ void to_api_model_returns_null_when_response_is_null() {
+ assertThat(mapper.toApiModel( (ProjectComponentListResponse) null)).isNull();
+ }
+
+ @Test
+ void to_api_model_maps_all_fields_when_response_contains_data_and_pagination() {
+ ProjectComponentListItem item = new ProjectComponentListItem();
+ item.setComponentId("comp-1");
+ item.setProjectKey("PRJ1");
+ item.setCaller("user@example.com");
+ item.setCatalogItemSlug("java-17");
+ item.setCreatedAt(new BigDecimal("1707043200000"));
+ item.setUpdatedAt(new BigDecimal("1707043300000"));
+
+ Pagination pagination = new Pagination();
+ pagination.setPage(1);
+ pagination.setSize(20);
+ pagination.setTotalPages(3);
+ pagination.setTotalElements(57);
+ pagination.setNext(URI.create("https://api.example.com/resources?page=2&size=20"));
+ pagination.setPrevious(URI.create("https://api.example.com/resources?page=0&size=20"));
+
+ ProjectComponentListResponse response = new ProjectComponentListResponse();
+ response.setData(List.of(item));
+ response.setPagination(pagination);
+
+ MarketplaceProjectComponentsMetrics result = mapper.toApiModel(response);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getData()).hasSize(1);
+
+ MarketplaceProjectComponentMetrics mappedItem = result.getData().getFirst();
+ assertThat(mappedItem.getComponentId()).isEqualTo("comp-1");
+ assertThat(mappedItem.getProjectKey()).isEqualTo("PRJ1");
+ assertThat(mappedItem.getCaller()).isEqualTo("user@example.com");
+ assertThat(mappedItem.getCatalogItemSlug()).isEqualTo("java-17");
+ assertThat(mappedItem.getCreatedAt()).isEqualByComparingTo("1707043200000");
+ assertThat(mappedItem.getUpdatedAt()).isEqualByComparingTo("1707043300000");
+
+ assertThat(result.getPagination()).isNotNull();
+ assertThat(result.getPagination().getPage()).isEqualTo(1);
+ assertThat(result.getPagination().getSize()).isEqualTo(20);
+ assertThat(result.getPagination().getTotalPages()).isEqualTo(3);
+ assertThat(result.getPagination().getTotalElements()).isEqualTo(57);
+ assertThat(result.getPagination().getNext().isPresent()).isTrue();
+ assertThat(result.getPagination().getNext().get())
+ .isEqualTo(URI.create("https://api.example.com/resources?page=2&size=20"));
+ assertThat(result.getPagination().getPrevious().isPresent()).isTrue();
+ assertThat(result.getPagination().getPrevious().get())
+ .isEqualTo(URI.create("https://api.example.com/resources?page=0&size=20"));
+ }
+
+ @Test
+ void to_api_model_maps_null_item_in_data_list_as_null_entry() {
+ ProjectComponentListResponse response = new ProjectComponentListResponse();
+ response.setData(Collections.singletonList(null));
+
+ MarketplaceProjectComponentsMetrics result = mapper.toApiModel(response);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getData()).hasSize(1);
+ assertThat(result.getData().getFirst()).isNull();
+ }
+
+ @Test
+ void to_api_model_keeps_default_data_and_sets_null_pagination_when_source_has_null_data_and_pagination() {
+ ProjectComponentListResponse response = new ProjectComponentListResponse();
+ response.setData(null);
+ response.setPagination(null);
+
+ MarketplaceProjectComponentsMetrics result = mapper.toApiModel(response);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getData()).isEmpty();
+ assertThat(result.getPagination()).isNull();
+ }
+
+ @Test
+ void to_api_model_for_catalog_items_list_throws_exception_when_list_is_null() {
+ assertThatThrownBy(() -> mapper.toApiModel((List) null))
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ @Test
+ void to_api_model_for_catalog_items_list_maps_empty_list() {
+ List emptyList = Collections.emptyList();
+
+ MarketplaceCatalogItemsMetrics result = mapper.toApiModel(emptyList);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getData()).isEmpty();
+ }
+
+ @Test
+ void to_api_model_for_catalog_items_list_maps_items_correctly() {
+ org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem sourceItem = new org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem();
+ sourceItem.setId("item-1");
+ sourceItem.setSlug("java-17");
+ sourceItem.setPath("/path/to/item");
+ sourceItem.setTitle("Java 17");
+ sourceItem.setShortDescription("Short desc");
+ sourceItem.setDescription("Full description");
+ sourceItem.setItemSrc("http://example.com/item");
+ sourceItem.setAuthors(List.of("author1", "author2"));
+
+ MarketplaceCatalogItemsMetrics result = mapper.toApiModel(List.of(sourceItem));
+
+ assertThat(result).isNotNull();
+ assertThat(result.getData()).hasSize(1);
+
+ CatalogItem mappedItem = result.getData().getFirst();
+ assertThat(mappedItem.getId()).isEqualTo("item-1");
+ assertThat(mappedItem.getSlug()).isEqualTo("java-17");
+ assertThat(mappedItem.getPath()).isEqualTo("/path/to/item");
+ assertThat(mappedItem.getTitle()).isEqualTo("Java 17");
+ assertThat(mappedItem.getShortDescription()).isEqualTo("Short desc");
+ assertThat(mappedItem.getDescription()).isEqualTo("Full description");
+ assertThat(mappedItem.getItemSrc()).isEqualTo("http://example.com/item");
+ assertThat(mappedItem.getAuthors()).containsExactly("author1", "author2");
+ }
+
+ @Test
+ void to_api_model_for_catalog_item_returns_null_when_item_is_null() {
+ assertThatThrownBy(() -> mapper.toApiModel((org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem) null))
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ @Test
+ void to_api_model_for_catalog_item_maps_all_fields_when_populated() {
+ org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem sourceItem = new org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem();
+ sourceItem.setId("item-1");
+ sourceItem.setSlug("java-17");
+ sourceItem.setPath("/path/to/item");
+ sourceItem.setTitle("Java 17");
+ sourceItem.setShortDescription("Short desc");
+ sourceItem.setDescriptionFileId("file-id-1");
+ sourceItem.setImageFileId("image-id-1");
+ sourceItem.setDescription("Full description");
+ sourceItem.setItemSrc("http://example.com/item");
+ sourceItem.setAuthors(List.of("author1"));
+ sourceItem.setDate(OffsetDateTime.now());
+
+ org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag sourceTag =
+ new org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag();
+ sourceTag.setLabel("version");
+ sourceTag.setOptions(Set.of("1.0", "2.0"));
+ sourceItem.setTags(List.of(sourceTag));
+
+ CatalogItem result = mapper.toApiModel(sourceItem);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getId()).isEqualTo("item-1");
+ assertThat(result.getSlug()).isEqualTo("java-17");
+ assertThat(result.getPath()).isEqualTo("/path/to/item");
+ assertThat(result.getTitle()).isEqualTo("Java 17");
+ assertThat(result.getShortDescription()).isEqualTo("Short desc");
+ assertThat(result.getDescriptionFileId()).isEqualTo("file-id-1");
+ assertThat(result.getImageFileId()).isEqualTo("image-id-1");
+ assertThat(result.getDescription()).isEqualTo("Full description");
+ assertThat(result.getItemSrc()).isEqualTo("http://example.com/item");
+ assertThat(result.getAuthors()).containsExactly("author1");
+ assertThat(result.getDate()).isNotNull();
+ assertThat(result.getTags()).hasSize(1);
+ assertThat(result.getTags().getFirst().getLabel()).isEqualTo("version");
+ assertThat(result.getTags().getFirst().getOptions()).contains("1.0", "2.0");
+ }
+
+ @Test
+ void to_api_model_for_catalog_item_maps_with_null_fields() {
+ org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem sourceItem = new org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem();
+ sourceItem.setId("item-1");
+ sourceItem.setTitle("Title");
+
+ CatalogItem result = mapper.toApiModel(sourceItem);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getId()).isEqualTo("item-1");
+ assertThat(result.getTitle()).isEqualTo("Title");
+ assertThat(result.getSlug()).isNull();
+ assertThat(result.getPath()).isNull();
+ assertThat(result.getDescription()).isNull();
+ assertThat(result.getTags()).isEmpty();
+ assertThat(result.getAuthors()).isEmpty();
+ }
+
+ @Test
+ void to_api_model_for_catalog_item_tag_returns_null_when_tag_is_null() {
+ assertThatThrownBy(() -> mapper.toApiModel((org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag) null))
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ @Test
+ void to_api_model_for_catalog_item_tag_maps_all_fields_when_populated() {
+ org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag sourceTag =
+ new org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag();
+ sourceTag.setLabel("version");
+ sourceTag.setOptions(Set.of("1.0", "2.0", "3.0"));
+
+ CatalogItemTag result = mapper.toApiModel(sourceTag);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getLabel()).isEqualTo("version");
+ assertThat(result.getOptions()).hasSize(3).contains("1.0", "2.0", "3.0");
+ }
+
+ @Test
+ void to_api_model_for_catalog_item_tag_maps_with_null_options() {
+ org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag sourceTag =
+ new org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag();
+ sourceTag.setLabel("environment");
+ sourceTag.setOptions(null);
+
+ CatalogItemTag result = mapper.toApiModel(sourceTag);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getLabel()).isEqualTo("environment");
+ assertThat(result.getOptions()).isEmpty();
+ }
+
+ @Test
+ void to_api_model_for_catalog_item_maps_multiple_tags() {
+ org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem sourceItem = new org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem();
+ sourceItem.setId("item-1");
+
+ org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag tag1 =
+ new org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag();
+ tag1.setLabel("version");
+ tag1.setOptions(Set.of("1.0", "2.0"));
+
+ org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag tag2 =
+ new org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag();
+ tag2.setLabel("environment");
+ tag2.setOptions(Set.of("prod", "dev"));
+
+ sourceItem.setTags(List.of(tag1, tag2));
+
+ CatalogItem result = mapper.toApiModel(sourceItem);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getTags()).hasSize(2);
+ assertThat(result.getTags().get(0).getLabel()).isEqualTo("version");
+ assertThat(result.getTags().get(0).getOptions()).contains("1.0", "2.0");
+ assertThat(result.getTags().get(1).getLabel()).isEqualTo("environment");
+ assertThat(result.getTags().get(1).getOptions()).contains("prod", "dev");
+ }
+}
+
diff --git a/api-project-component-v0/pom.xml b/api-project-component-v0/pom.xml
index 495e438e..92ba26f1 100644
--- a/api-project-component-v0/pom.xml
+++ b/api-project-component-v0/pom.xml
@@ -6,7 +6,7 @@
org.opendevstack.apiservice
devstack-api-service
- 0.0.3
+ 0.0.4
api-project-component-v0
diff --git a/api-project-platform/pom.xml b/api-project-platform/pom.xml
index abf2834d..75241ed3 100644
--- a/api-project-platform/pom.xml
+++ b/api-project-platform/pom.xml
@@ -6,7 +6,7 @@
org.opendevstack.apiservice
devstack-api-service
- 0.0.3
+ 0.0.4
api-project-platform
diff --git a/api-project-users/pom.xml b/api-project-users/pom.xml
index c75bf700..4378fac5 100644
--- a/api-project-users/pom.xml
+++ b/api-project-users/pom.xml
@@ -6,7 +6,7 @@
org.opendevstack.apiservice
devstack-api-service
- 0.0.3
+ 0.0.4
api-project-users
diff --git a/api-project/pom.xml b/api-project/pom.xml
index 799043f6..a8baf158 100644
--- a/api-project/pom.xml
+++ b/api-project/pom.xml
@@ -6,7 +6,7 @@
org.opendevstack.apiservice
devstack-api-service
- 0.0.3
+ 0.0.4
api-project
diff --git a/application.yaml b/application.yaml
index f276531c..b312c819 100644
--- a/application.yaml
+++ b/application.yaml
@@ -68,6 +68,13 @@ app:
- /actuator/health
- /actuator/info
- /api/v1/projects/*/platforms
+ client-credentials:
+ # Azure AD token endpoint used for request a token.
+ token-url: ${CLIENT_CREDENTIALS_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: ${CLIENT_CREDENTIALS_CLIENT_ID}
+ # Client secret for token request. Must be injected via environment variable or secret store.
+ client-secret: ${CLIENT_CREDENTIALS_CLIENT_SECRET}
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}
@@ -126,7 +133,7 @@ otel:
service:
# Logical service name and version attached to telemetry data.
name: devstack-api-service-dev
- version: 0.0.3
+ version: 0.0.4
exporter:
otlp:
# Endpoint of the OpenTelemetry collector.
@@ -142,7 +149,7 @@ otel:
exporter: none
resource:
# Resource attributes attached to every exported span.
- attributes: service.name=devstack-api-service,service.version=0.0.3,deployment.environment=development
+ attributes: service.name=devstack-api-service,service.version=0.0.4,deployment.environment=development
instrumentation:
jdbc:
# JDBC instrumentation is disabled, likely to reduce noise or overhead.
@@ -315,3 +322,4 @@ externalservices:
trust-all-certificates: ${MARKETPLACE_TRUST_ALL_CERTS:false}
username: ${MARKETPLACE_USERNAME:}
password: ${MARKETPLACE_PASSWORD:}
+ client-credentials-scope: ${MARKETPLACE_CLIENT_CREDENTIALS_SCOPE:}
diff --git a/core-contracts/pom.xml b/core-contracts/pom.xml
index 1db3a6d3..c4942212 100644
--- a/core-contracts/pom.xml
+++ b/core-contracts/pom.xml
@@ -8,7 +8,7 @@
org.opendevstack.apiservice
devstack-api-service
- 0.0.3
+ 0.0.4
core-contracts
diff --git a/core-security/pom.xml b/core-security/pom.xml
index 04bc8fb6..8240d73f 100644
--- a/core-security/pom.xml
+++ b/core-security/pom.xml
@@ -7,7 +7,7 @@
org.opendevstack.apiservice
devstack-api-service
- 0.0.3
+ 0.0.4
core-security
diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenException.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenException.java
new file mode 100644
index 00000000..f8d5eb78
--- /dev/null
+++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenException.java
@@ -0,0 +1,12 @@
+package org.opendevstack.apiservice.core.security.client.crendentials;
+
+public class ClientCredentialsTokenException extends RuntimeException {
+
+ public ClientCredentialsTokenException(String message) {
+ super(message);
+ }
+
+ public ClientCredentialsTokenException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenProperties.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenProperties.java
new file mode 100644
index 00000000..0db8e60a
--- /dev/null
+++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenProperties.java
@@ -0,0 +1,17 @@
+package org.opendevstack.apiservice.core.security.client.crendentials;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@ConfigurationProperties(prefix = "app.security.client-credentials")
+@Data
+public class ClientCredentialsTokenProperties {
+
+ private String tokenUrl;
+
+ private String clientId;
+
+ private String clientSecret;
+}
diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenResponse.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenResponse.java
new file mode 100644
index 00000000..55cbe968
--- /dev/null
+++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenResponse.java
@@ -0,0 +1,25 @@
+package org.opendevstack.apiservice.core.security.client.crendentials;
+
+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 ClientCredentialsTokenResponse {
+
+ @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/client/crendentials/ClientCredentialsTokenService.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenService.java
new file mode 100644
index 00000000..b9c954dd
--- /dev/null
+++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenService.java
@@ -0,0 +1,66 @@
+package org.opendevstack.apiservice.core.security.client.crendentials;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+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 ClientCredentialsTokenService {
+
+ private static final String GRANT_TYPE = "client_credentials";
+
+ private final ClientCredentialsTokenProperties properties;
+ private final RestTemplate restTemplate;
+
+ @Autowired
+ public ClientCredentialsTokenService(ClientCredentialsTokenProperties properties, RestTemplate restTemplate) {
+ this.properties = properties;
+ this.restTemplate = restTemplate;
+ }
+
+ /**
+ * Requests an access token using the given JWT assertion.
+ *
+ * @param scope the target API scope (e.g. {@code api:///Api.Access})
+ *
+ * @return the access token string
+ * @throws ClientCredentialsTokenException if the token request fails
+ */
+ public String requestToken(String scope) {
+ log.debug("Requesting access token using JWT assertion");
+
+ MultiValueMap body = new LinkedMultiValueMap<>();
+ body.add("grant_type", GRANT_TYPE);
+ body.add("client_id", properties.getClientId());
+ body.add("client_secret", properties.getClientSecret());
+ 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, ClientCredentialsTokenResponse.class);
+
+ if (response.getBody() == null || response.getBody().getAccessToken() == null) {
+ throw new ClientCredentialsTokenException("JWT token response body or access_token is null");
+ }
+
+ log.debug("JWT token obtained successfully, expires in {} seconds", response.getBody().getExpiresIn());
+ return response.getBody().getAccessToken();
+ } catch (RestClientException e) {
+ throw new ClientCredentialsTokenException("Failed to exchange JWT for JWT token: " + e.getMessage(), e);
+ }
+ }
+}
diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java
index 48f4a2a0..6df3118d 100644
--- a/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java
+++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java
@@ -35,7 +35,7 @@ void setUp() {
securityConfig = new SecurityConfig(
securityProperties,
policyAuthorizationManager,
- azureJwtAuthenticationConverter,
+ azureJwtAuthenticationConverter,
cachedBodyRequestFilter
);
}
diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/ClientCredentialsTokenServiceTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/ClientCredentialsTokenServiceTest.java
new file mode 100644
index 00000000..4e143fee
--- /dev/null
+++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/ClientCredentialsTokenServiceTest.java
@@ -0,0 +1,137 @@
+package org.opendevstack.apiservice.core.security.jwt;
+
+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.opendevstack.apiservice.core.security.client.crendentials.ClientCredentialsTokenException;
+import org.opendevstack.apiservice.core.security.client.crendentials.ClientCredentialsTokenProperties;
+import org.opendevstack.apiservice.core.security.client.crendentials.ClientCredentialsTokenResponse;
+import org.opendevstack.apiservice.core.security.client.crendentials.ClientCredentialsTokenService;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+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 ClientCredentialsTokenServiceTest {
+
+ @Mock
+ private RestTemplate restTemplate;
+
+ private ClientCredentialsTokenProperties properties;
+
+ private ClientCredentialsTokenService sut;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ properties = new ClientCredentialsTokenProperties();
+ properties.setTokenUrl("https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token");
+ properties.setClientId("test-client-id");
+ properties.setClientSecret("test-client-secret");
+ sut = new ClientCredentialsTokenService(properties, restTemplate);
+ }
+
+ @Test
+ void request_token_returns_access_token_on_success() {
+ // GIVEN
+ String scope = "api://target-app/.default";
+ ClientCredentialsTokenResponse tokenResponse = new ClientCredentialsTokenResponse();
+ tokenResponse.setAccessToken("jwt-access-token");
+ tokenResponse.setTokenType("Bearer");
+ tokenResponse.setExpiresIn(3600);
+ tokenResponse.setScope(scope);
+
+ String expectedUrl = properties.getTokenUrl();
+
+ when(restTemplate.postForEntity(eq(expectedUrl), any(HttpEntity.class), eq(ClientCredentialsTokenResponse.class)))
+ .thenReturn(new ResponseEntity<>(tokenResponse, HttpStatus.OK));
+
+ // WHEN
+ String result = sut.requestToken(scope);
+
+ // THEN
+ assertEquals("jwt-access-token", result);
+ verify(restTemplate).postForEntity(eq(expectedUrl), any(HttpEntity.class), eq(ClientCredentialsTokenResponse.class));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void request_token_sends_correct_form_parameters_and_headers() {
+ // GIVEN
+ String scope = "api://app/scope";
+ ClientCredentialsTokenResponse tokenResponse = new ClientCredentialsTokenResponse();
+ tokenResponse.setAccessToken("token");
+
+ ArgumentCaptor>> captor = ArgumentCaptor.forClass(HttpEntity.class);
+ when(restTemplate.postForEntity(anyString(), captor.capture(), eq(ClientCredentialsTokenResponse.class)))
+ .thenReturn(new ResponseEntity<>(tokenResponse, HttpStatus.OK));
+
+ // WHEN
+ sut.requestToken(scope);
+
+ // THEN
+ MultiValueMap body = captor.getValue().getBody();
+ assertNotNull(body);
+ assertEquals("client_credentials", body.getFirst("grant_type"));
+ assertEquals("test-client-id", body.getFirst("client_id"));
+ assertEquals("test-client-secret", body.getFirst("client_secret"));
+ assertEquals("api://app/scope", body.getFirst("scope"));
+
+ MediaType contentType = captor.getValue().getHeaders().getContentType();
+ assertNotNull(contentType);
+ assertEquals(MediaType.APPLICATION_FORM_URLENCODED, contentType);
+ }
+
+ @Test
+ void request_token_throws_jwt_exception_when_response_body_is_null() {
+ // GIVEN
+ when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(ClientCredentialsTokenResponse.class)))
+ .thenReturn(new ResponseEntity<>(null, HttpStatus.OK));
+
+ // WHEN / THEN
+ ClientCredentialsTokenException ex = assertThrows(ClientCredentialsTokenException.class,
+ () -> sut.requestToken("scope"));
+ assertTrue(ex.getMessage().contains("null"));
+ }
+
+ @Test
+ void request_token_throws_jwt_exception_when_access_token_is_null() {
+ // GIVEN
+ ClientCredentialsTokenResponse response = new ClientCredentialsTokenResponse();
+ response.setAccessToken(null);
+
+ when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(ClientCredentialsTokenResponse.class)))
+ .thenReturn(new ResponseEntity<>(response, HttpStatus.OK));
+
+ // WHEN / THEN
+ assertThrows(ClientCredentialsTokenException.class, () -> sut.requestToken("scope"));
+ }
+
+ @Test
+ void request_token_throws_jwt_exception_on_rest_client_error() {
+ // GIVEN
+ when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(ClientCredentialsTokenResponse.class)))
+ .thenThrow(new RestClientException("Connection refused"));
+
+ // WHEN / THEN
+ ClientCredentialsTokenException ex = assertThrows(ClientCredentialsTokenException.class,
+ () -> sut.requestToken("scope"));
+ assertTrue(ex.getMessage().contains("Connection refused"));
+ }
+}
+
diff --git a/core/pom.xml b/core/pom.xml
index a0ba5310..97cf4504 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -6,7 +6,7 @@
org.opendevstack.apiservice
devstack-api-service
- 0.0.3
+ 0.0.4
core
@@ -145,6 +145,12 @@
api-project-platform
${project.version}
+
+
+ org.opendevstack.apiservice
+ api-marketplace-metrics
+ ${project.version}
+
diff --git a/external-service-aap/pom.xml b/external-service-aap/pom.xml
index 0c80ffe9..d108ce76 100644
--- a/external-service-aap/pom.xml
+++ b/external-service-aap/pom.xml
@@ -6,7 +6,7 @@
org.opendevstack.apiservice
devstack-api-service
- 0.0.3
+ 0.0.4
external-service-aap
diff --git a/external-service-api/pom.xml b/external-service-api/pom.xml
index b6957371..7e163de5 100644
--- a/external-service-api/pom.xml
+++ b/external-service-api/pom.xml
@@ -7,7 +7,7 @@
org.opendevstack.apiservice
devstack-api-service
- 0.0.3
+ 0.0.4
external-service-api
diff --git a/external-service-bitbucket/pom.xml b/external-service-bitbucket/pom.xml
index 39467b78..bb654b96 100644
--- a/external-service-bitbucket/pom.xml
+++ b/external-service-bitbucket/pom.xml
@@ -6,7 +6,7 @@
org.opendevstack.apiservice
devstack-api-service
- 0.0.3
+ 0.0.4
external-service-bitbucket
diff --git a/external-service-jira/pom.xml b/external-service-jira/pom.xml
index f89d4567..703a15ab 100644
--- a/external-service-jira/pom.xml
+++ b/external-service-jira/pom.xml
@@ -6,7 +6,7 @@
org.opendevstack.apiservice
devstack-api-service
- 0.0.3
+ 0.0.4
external-service-jira
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
index 6bf9eb3c..bafbb6de 100644
--- a/external-service-marketplace/openapi/openapi-component_catalog-v1.0.0.yaml
+++ b/external-service-marketplace/openapi/openapi-component_catalog-v1.0.0.yaml
@@ -307,7 +307,7 @@ paths:
- name: catalogId
in: query
description: id for the Catalog.
- required: true
+ required: false
schema:
type: string
- name: sortByTitle
@@ -1000,6 +1000,10 @@ components:
imageFileId:
type: string
example: cHJvam...YXN0Z
+ description:
+ type: string
+ description: Resolved description content of the catalog item (e.g. markdown converted to plain text or HTML)
+ example: "This component provides a reusable service for handling..."
itemSrc:
type: string
example: 'https://bitbucket.some-company.com/projects/SOMEPROJECT/repos/some-repo/browse/CatalogItem.yaml?at=refs/heads/master'
@@ -1040,6 +1044,8 @@ components:
shortDescription: This is a short description for the item
descriptionFileId: cHJvam...0ZXIK
imageFileId: cHJvam...YXN0Z
+ description: "This component provides a reusable service for handling..."
+ image: "https://example.com/images/component.png"
itemSrc: https://bitbucket.some-company.com/projects/SOMEPROJECT/repos/some-repo/browse/CatalogItem.yaml?at=refs/heads/master
tags:
- label: data
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
index 2cf2681e..d7fe8498 100644
--- a/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml
+++ b/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml
@@ -366,6 +366,57 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/RestErrorMessage'
+
+ /project/components:
+ get:
+ tags:
+ - Project-components-with-provision-status
+ operationId: getAllProjectComponents
+ parameters:
+ - name: page
+ in: query
+ description: Page number (from 0 until limit)
+ required: false
+ schema:
+ type: integer
+ default: 0
+
+ - name: size
+ in: query
+ description: Page size
+ required: false
+ schema:
+ type: integer
+ default: 20
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ProjectComponentListResponse"
+ description: A paginated list of all the provisioned project components
+ "401":
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/RestErrorMessage"
+ description: Invalid client token on the request.
+ "403":
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/RestErrorMessage"
+ description: Insufficient permissions for the client to access the resource.
+ "500":
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/RestErrorMessage"
+ description: Server error.
+ summary: Returns the information of the project's components in the Bitbucket
+ repository.
+ x-accepts:
+ - application/json
components:
securitySchemes:
bearerAuth:
@@ -610,4 +661,109 @@ components:
parameters:
type: array
items:
- $ref: '#/components/schemas/ProjectComponentStatusParameter'
\ No newline at end of file
+ $ref: '#/components/schemas/ProjectComponentStatusParameter'
+ ProjectComponentListItem:
+ type: object
+ example:
+ projectKey: SOMEPROJECT
+ componentId: any-component-id-from-backend
+ caller: some-person@email.com
+ catalogItemSlug: some_technology-name
+ createdAt: 1707043200000
+ updatedAt: 1707043200000
+ properties:
+ projectKey:
+ description: The projectKey which the component is provisioned for.
+ example: SOMEPROJECT
+ type: string
+ componentId:
+ description: The componentId set by the user.
+ example: any-component-id-from-backend
+ minLength: 1
+ pattern: ^(?!\s*$).+
+ type: string
+ caller:
+ description: The email of who provisioned the component.
+ example: some-person@email.com
+ type: string
+ catalogItemSlug:
+ description: The provisioned catalog item slug.
+ example: some_technology-name
+ type: string
+ createdAt:
+ description: The timestamp of the provision action.
+ example: 1707043200000
+ type: number
+ updatedAt:
+ description: The timestamp of the last change of the provisioned component.
+ example: 1707043200000
+ type: number
+ Pagination:
+ type: object
+ example:
+ page: 0
+ size: 20
+ totalElements: 117
+ totalPages: 6
+ next: "https://api.example.com/resources?page=1&size=20"
+ previous: null
+ properties:
+ page:
+ type: integer
+ example: 0
+ description: Current page of the response.
+ size:
+ type: integer
+ example: 20
+ description: Current page size of the response.
+ totalElements:
+ type: integer
+ format: integer
+ example: 117
+ description: Total number of elements.
+ totalPages:
+ type: integer
+ example: 6
+ description: Total number of pages of this exact size.
+ next:
+ type: string
+ format: uri
+ nullable: true
+ example: "https://api.example.com/resources?page=1&size=20"
+ description: URL of the next page (or null if the current is the last one)
+ previous:
+ type: string
+ format: uri
+ nullable: true
+ example: "https://api.example.com/resources?page=0&size=20"
+ description: URL of the previous page (or null if the current is the first one)
+ ProjectComponentListResponse:
+ type: object
+ example:
+ data:
+ - projectKey: SOMEPROJECT
+ componentId: any-component-id-from-backend
+ caller: some-person@email.com
+ catalogItemSlug: some_technology-name
+ createdAt: 1707043200000
+ updatedAt: 1707043200000
+ - projectKey: ANOTHERPROJECT
+ componentId: another-component-id-from-backend
+ caller: some-person2@email.com
+ catalogItemSlug: another_technology-name
+ createdAt: 1707043200001
+ updatedAt: 1707043200002
+ pagination:
+ page: 0
+ size: 20
+ totalElements: 117
+ totalPages: 6
+ next: https://api.example.com/resources?page=1&size=20
+ previous: null
+ properties:
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/ProjectComponentListItem'
+ pagination:
+ $ref: '#/components/schemas/Pagination'
diff --git a/external-service-marketplace/pom.xml b/external-service-marketplace/pom.xml
index d3b19911..b43b4bea 100644
--- a/external-service-marketplace/pom.xml
+++ b/external-service-marketplace/pom.xml
@@ -6,7 +6,7 @@
org.opendevstack.apiservice
devstack-api-service
- 0.0.3
+ 0.0.4
external-service-marketplace
@@ -150,7 +150,7 @@
generate
- FILTER=operationId:getCatalogItemById|getCatalogItemBySlug|getCatalogHealth
+ FILTER=operationId:getCatalogItemById|getCatalogItemBySlug|getCatalogItems|getCatalogHealth
java
resttemplate
@@ -186,7 +186,7 @@
generate
- FILTER=operationId:triggerProvisionAction|notifyProvisioningStatusUpdate|deleteProjectComponent|getProjectComponentProvisionStatusById|getProvisionerHealth
+ FILTER=operationId:triggerProvisionAction|notifyProvisioningStatusUpdate|deleteProjectComponent|getProjectComponentProvisionStatusById|getAllProjectComponents|getProvisionerHealth
java
resttemplate
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
index 5f0302fb..5083ae2b 100644
--- 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
@@ -51,6 +51,13 @@ public class MarketplaceInstanceConfig {
*/
private String oboScope;
+
+ /**
+ * OAuth2 scope used for Client Credentials token exchange when calling this Marketplace instance.
+ * Example: {@code api:///.default}
+ */
+ private String clientCredentialsScope;
+
/**
* Bypass configuration. When the incoming token already targets the configured bypass
* audience and scope, the OBO token exchange is skipped and the token is forwarded as-is.
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 98fc9341..9857250a 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,6 +1,7 @@
package org.opendevstack.apiservice.externalservice.marketplace.service;
import org.opendevstack.apiservice.externalservice.api.ExternalService;
+import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListResponse;
import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException;
import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem;
import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus;
@@ -22,10 +23,18 @@ public interface MarketplaceService extends ExternalService {
ProjectComponentProvisionStatus getProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException;
+ ProjectComponentListResponse getAllProjectComponents(Integer page, Integer size) throws MarketplaceException;
+
+ ProjectComponentListResponse getAllProjectComponents(String instanceName, Integer page, Integer size) throws MarketplaceException;
+
CatalogItem getCatalogItem(String catalogItemId) throws MarketplaceException;
CatalogItem getCatalogItem(String instanceName, String catalogItemId) throws MarketplaceException;
+ List getAllCatalogItems() throws MarketplaceException;
+
+ List getAllCatalogItems(String instanceName) throws MarketplaceException;
+
CatalogItem getCatalogItemBySlug(String slug) throws MarketplaceException;
CatalogItem getCatalogItemBySlug(String instanceName, String slug) throws MarketplaceException;
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
index 74464ae7..a8e910b6 100644
--- 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
@@ -1,10 +1,12 @@
package org.opendevstack.apiservice.externalservice.marketplace.service.impl;
import lombok.extern.slf4j.Slf4j;
+import org.opendevstack.apiservice.core.security.client.crendentials.ClientCredentialsTokenService;
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.client.model.*;
import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceInstanceConfig;
import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException;
import org.opendevstack.apiservice.externalservice.marketplace.client.ApiClient;
@@ -14,21 +16,12 @@
import org.opendevstack.apiservice.externalservice.marketplace.client.api.ProvisionResultsApi;
import org.opendevstack.apiservice.externalservice.marketplace.client.api.ProvisionerActionsApi;
import org.opendevstack.apiservice.externalservice.marketplace.client.api.ProvisionerHealthApi;
-import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem;
-import org.opendevstack.apiservice.externalservice.marketplace.client.model.GetCatalogHealth200Response;
-import org.opendevstack.apiservice.externalservice.marketplace.client.model.GetProvisionerHealth200Response;
-import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus;
-import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionAction;
-import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionParameter;
-import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionResponse;
-import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningDeleteRequest;
-import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequest;
-import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequestAllOfParameters;
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.Collections;
import java.util.List;
import java.util.Set;
@@ -39,11 +32,14 @@ public class MarketplaceServiceImpl implements MarketplaceService {
private final MarketplaceApiClientFactory clientFactory;
private final OboTokenService oboTokenService;
+ private final ClientCredentialsTokenService clientCredentialsTokenService;
public MarketplaceServiceImpl(MarketplaceApiClientFactory clientFactory,
- OboTokenService oboTokenService) {
+ OboTokenService oboTokenService,
+ ClientCredentialsTokenService clientCredentialsTokenService) {
this.clientFactory = clientFactory;
this.oboTokenService = oboTokenService;
+ this.clientCredentialsTokenService = clientCredentialsTokenService;
log.info("MarketplaceServiceImpl initialized");
}
@@ -77,6 +73,35 @@ public CatalogItem getCatalogItem(String instanceName, String catalogItemId) thr
}
}
+ @Override
+ public List getAllCatalogItems() throws MarketplaceException {
+ return getAllCatalogItems(getDefaultInstance());
+ }
+
+ @Override
+ public List getAllCatalogItems(String instanceName) throws MarketplaceException {
+ log.debug("Marketplace service GET all catalog items in instance {} ", instanceName);
+
+ try {
+ MarketplaceApiClient marketplaceClient = getOboAuthenticatedClient(instanceName);
+ ApiClient apiClient = marketplaceClient.getApiClient();
+ CatalogItemsApi catalogItemsApi = new CatalogItemsApi(apiClient);
+
+ apiClient.setBasePath(marketplaceClient.getConfig().getProjectComponentsBaseUrl());
+
+ return catalogItemsApi.getCatalogItems(SortOrder.ASC, null);
+ } catch (HttpClientErrorException.NotFound e) {
+ log.debug("Catalog items not found in Marketplace instance '{}'", instanceName);
+ return Collections.emptyList();
+ } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) {
+ throw new MarketplaceException(
+ String.format("Access denied when getting all catalog items in instance '%s'", instanceName), e);
+ } catch (RestClientException e) {
+ throw new MarketplaceException(
+ String.format("Failed to retrieve catalog items in instance '%s'", instanceName), e);
+ }
+ }
+
@Override
public CatalogItem getCatalogItemBySlug(String slug) throws MarketplaceException {
return getCatalogItemBySlug(getDefaultInstance(), slug);
@@ -111,6 +136,28 @@ public ProjectComponentProvisionStatus getProjectComponent(String projectId, Str
return getProjectComponent(getDefaultInstance(), projectId, componentId);
}
+ public ProjectComponentListResponse getAllProjectComponents(String instanceName, Integer page, Integer size) throws MarketplaceException {
+ log.debug("Marketplace service GET all components with page {} and size {} in instance {} ", page, size, instanceName);
+ try {
+ MarketplaceApiClient marketplaceClient = getClientCredentialsAuthenticatedClient(instanceName);
+ ApiClient apiClient = marketplaceClient.getApiClient();
+ ProjectComponentsWithProvisionStatusApi projectComponentsApi = new ProjectComponentsWithProvisionStatusApi(apiClient);
+ apiClient.setBasePath(marketplaceClient.getConfig().getProvisionerActionsBaseUrl());
+ return projectComponentsApi.getAllProjectComponents(page, size);
+ } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) {
+ throw new MarketplaceException(
+ String.format("Access denied when getting all project components in instance '%s'",
+ instanceName), e);
+ } catch (RestClientException e) {
+ throw new MarketplaceException(
+ String.format("Failed to retrieve all project components in instance '%s'", instanceName), e);
+ }
+ }
+
+ public ProjectComponentListResponse getAllProjectComponents(Integer page, Integer size) throws MarketplaceException {
+ return getAllProjectComponents(getDefaultInstance(), page, size);
+ }
+
@Override
public ProjectComponentProvisionStatus getProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException {
log.debug("Marketplace service GET component with id {} for project {} in instance {} ", componentId, projectId, instanceName);
@@ -371,4 +418,28 @@ private String getOboToken(String instanceName, String assertion, String oboScop
ex);
}
}
+
+ /**
+ * Creates a {@link MarketplaceApiClient} authenticated with a JWT token
+ * obtained using the client credentials flow.
+ */
+ private MarketplaceApiClient getClientCredentialsAuthenticatedClient(String instanceName) throws MarketplaceException {
+ MarketplaceApiClient client = clientFactory.getClient(instanceName);
+
+ String jwtToken;
+
+ try {
+ String scope = client.getConfig().getClientCredentialsScope();
+
+ jwtToken = clientCredentialsTokenService.requestToken(scope);
+ } catch (RuntimeException ex) {
+ throw new MarketplaceException(
+ String.format(
+ "Failed to obtain JWT token for Marketplace instance '%s' using the client credentials flow.", instanceName),
+ ex);
+ }
+
+ client.setBearerToken(jwtToken);
+ return client;
+ }
}
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
index 417cb8f8..2b0facae 100644
--- 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
@@ -8,6 +8,7 @@
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.stubbing.OngoingStubbing;
+import org.opendevstack.apiservice.core.security.client.crendentials.ClientCredentialsTokenService;
import org.opendevstack.apiservice.core.security.obo.OboTokenService;
import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClient;
import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClientFactory;
@@ -16,6 +17,7 @@
import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException;
import org.opendevstack.apiservice.externalservice.marketplace.client.ApiClient;
import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem;
+import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListResponse;
import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus;
import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionResponse;
import org.opendevstack.apiservice.externalservice.marketplace.service.impl.MarketplaceServiceImpl;
@@ -62,6 +64,7 @@ class MarketplaceServiceImplTest {
// 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_PROJECT_COMPONENTS = "/project/components";
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}";
@@ -78,11 +81,14 @@ class MarketplaceServiceImplTest {
@Mock
private OboTokenService oboTokenService;
+ @Mock
+ private ClientCredentialsTokenService clientCredentialsTokenService;
+
private MarketplaceService marketplaceService;
@BeforeEach
void setUp() {
- marketplaceService = new MarketplaceServiceImpl(clientFactory, oboTokenService);
+ marketplaceService = new MarketplaceServiceImpl(clientFactory, oboTokenService, clientCredentialsTokenService);
// Set up a fake SecurityContext so JwtUtils.getTokenValue() works
Jwt jwt = Jwt.withTokenValue("test-jwt-assertion")
@@ -160,6 +166,104 @@ void testGetProjectComponent_InstanceNotConfigured() throws MarketplaceException
verify(clientFactory).getClient(instanceName);
}
+ // -------------------------------------------------------------------------
+ // getAllProjectComponents
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testGetAllProjectComponents_Success_ReturnsResponse() throws MarketplaceException {
+ // Arrange
+ String instanceName = "dev";
+ Integer page = 0;
+ Integer size = 10;
+ MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig();
+ instanceConfig.setOboScope("api://test/scope");
+
+ ProjectComponentListResponse mockResponse = new ProjectComponentListResponse();
+
+ when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient);
+ when(marketplaceApiClient.getApiClient()).thenReturn(apiClient);
+ when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig);
+ whenInvokeAPI(PATH_PROJECT_COMPONENTS, HttpMethod.GET)
+ .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK));
+
+ // Act
+ ProjectComponentListResponse result = marketplaceService.getAllProjectComponents(instanceName, page, size);
+
+ // Assert
+ assertEquals(mockResponse, result);
+ verify(clientFactory).getClient(instanceName);
+ }
+
+ @Test
+ void testGetAllProjectComponents_Unauthorized_ThrowsMarketplaceException() throws MarketplaceException {
+ // Arrange
+ String instanceName = "dev";
+ Integer page = 0;
+ Integer size = 10;
+ 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_COMPONENTS, HttpMethod.GET).thenThrow(forbiddenEx);
+
+ // Act & Assert
+ MarketplaceException exception = assertThrows(MarketplaceException.class, () ->
+ marketplaceService.getAllProjectComponents(instanceName, page, size));
+
+ assertTrue(exception.getMessage().contains("Access denied"));
+ }
+
+ @Test
+ void testGetAllProjectComponents_RestClientException_ThrowsMarketplaceException() throws MarketplaceException {
+ // Arrange
+ String instanceName = "dev";
+ Integer page = 0;
+ Integer size = 10;
+ 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_COMPONENTS, HttpMethod.GET).thenThrow(new RestClientException("Connection failed"));
+
+ // Act & Assert
+ MarketplaceException exception = assertThrows(MarketplaceException.class, () ->
+ marketplaceService.getAllProjectComponents(instanceName, page, size));
+
+ assertTrue(exception.getMessage().contains("Failed to retrieve all project components"));
+ }
+
+ @Test
+ void testGetAllProjectComponents_DefaultInstance_UsesDefaultClient() throws MarketplaceException {
+ // Arrange
+ Integer page = 0;
+ Integer size = 10;
+ MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig();
+ instanceConfig.setOboScope("api://test/scope");
+
+ ProjectComponentListResponse mockResponse = new ProjectComponentListResponse();
+
+ when(clientFactory.getDefaultInstanceName()).thenReturn("default");
+ when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient);
+ when(marketplaceApiClient.getApiClient()).thenReturn(apiClient);
+ when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig);
+ whenInvokeAPI(PATH_PROJECT_COMPONENTS, HttpMethod.GET)
+ .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK));
+
+ // Act
+ ProjectComponentListResponse result = marketplaceService.getAllProjectComponents(page, size);
+
+ // Assert
+ assertEquals(mockResponse, result);
+ verify(clientFactory).getClient("default");
+ }
+
@Test
void testGetProjectComponent_RestClientException() throws MarketplaceException {
// Arrange
@@ -215,7 +319,7 @@ void testGetProjectComponent_NotFound_ReturnsNull() throws MarketplaceException
void testIsHealthy_NoInstancesConfigured_ReturnsFalse() {
when(clientFactory.getAvailableInstances()).thenReturn(Set.of());
- MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) {
+ MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService, clientCredentialsTokenService) {
@Override
protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) {
return true;
@@ -238,7 +342,7 @@ void testIsHealthy_BothEndpointsUp_ReturnsTrue() throws MarketplaceException {
when(clientFactory.getDefaultInstanceName()).thenReturn("dev");
when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient);
- MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) {
+ MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService, clientCredentialsTokenService) {
@Override
protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) {
return true;
@@ -261,7 +365,7 @@ void testIsHealthy_ProvisionerDown_ReturnsFalse() throws MarketplaceException {
when(clientFactory.getDefaultInstanceName()).thenReturn("dev");
when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient);
- MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) {
+ MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService, clientCredentialsTokenService) {
@Override
protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) {
return false;
@@ -279,7 +383,7 @@ void testIsHealthy_CatalogDown_ReturnsFalse() throws MarketplaceException {
when(clientFactory.getDefaultInstanceName()).thenReturn("dev");
when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient);
- MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) {
+ MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService, clientCredentialsTokenService) {
@Override
protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) {
return true;
@@ -558,6 +662,120 @@ void testGetCatalogItem_DefaultInstance() throws MarketplaceException {
verify(clientFactory).getClient("default");
}
+ // -------------------------------------------------------------------------
+ // getAllCatalogItems
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testGetAllCatalogItems_Success_ReturnsList() throws MarketplaceException {
+ // Arrange
+ String instanceName = "dev";
+ MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig();
+ instanceConfig.setOboScope("api://test/scope");
+
+ CatalogItem item = new CatalogItem();
+ item.setId("cid");
+ List mockList = List.of(item);
+
+ when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient);
+ when(marketplaceApiClient.getApiClient()).thenReturn(apiClient);
+ when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig);
+ whenInvokeAPI("/catalog-items", HttpMethod.GET)
+ .thenReturn(new ResponseEntity<>(mockList, HttpStatus.OK));
+
+ // Act
+ List result = marketplaceService.getAllCatalogItems(instanceName);
+
+ // Assert
+ assertEquals(mockList, result);
+ verify(clientFactory).getClient(instanceName);
+ }
+
+ @Test
+ void testGetAllCatalogItems_NotFound_ReturnsEmptyList() throws MarketplaceException {
+ // Arrange
+ String instanceName = "dev";
+ 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("/catalog-items", HttpMethod.GET).thenThrow(notFoundEx);
+ when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig);
+
+ // Act
+ List result = marketplaceService.getAllCatalogItems(instanceName);
+
+ // Assert
+ assertTrue(result.isEmpty());
+ verify(clientFactory).getClient(instanceName);
+ }
+
+ @Test
+ void testGetAllCatalogItems_AuthError_ThrowsMarketplaceException() throws MarketplaceException {
+ // Arrange
+ String instanceName = "dev";
+ 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("/catalog-items", HttpMethod.GET).thenThrow(forbiddenEx);
+ when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig);
+
+ // Act & Assert
+ MarketplaceException exception = assertThrows(MarketplaceException.class, () ->
+ marketplaceService.getAllCatalogItems(instanceName));
+
+ assertTrue(exception.getMessage().contains("Access denied"));
+ }
+
+ @Test
+ void testGetAllCatalogItems_RestClientException_ThrowsMarketplaceException() throws MarketplaceException {
+ // Arrange
+ String instanceName = "dev";
+ 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("/catalog-items", HttpMethod.GET).thenThrow(new RestClientException("Connection failed"));
+
+ // Act & Assert
+ MarketplaceException exception = assertThrows(MarketplaceException.class, () ->
+ marketplaceService.getAllCatalogItems(instanceName));
+
+ assertTrue(exception.getMessage().contains("Failed to retrieve catalog items"));
+ }
+
+ @Test
+ void testGetAllCatalogItems_DefaultInstance_UsesDefaultClient() throws MarketplaceException {
+ // Arrange
+ MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig();
+ instanceConfig.setOboScope("api://test/scope");
+
+ List mockList = List.of(new CatalogItem());
+
+ when(clientFactory.getDefaultInstanceName()).thenReturn("default");
+ when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient);
+ when(marketplaceApiClient.getApiClient()).thenReturn(apiClient);
+ when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig);
+ whenInvokeAPI("/catalog-items", HttpMethod.GET)
+ .thenReturn(new ResponseEntity<>(mockList, HttpStatus.OK));
+
+ // Act
+ List result = marketplaceService.getAllCatalogItems();
+
+ // Assert
+ assertEquals(mockList, result);
+ verify(clientFactory).getClient("default");
+ }
+
// -------------------------------------------------------------------------
// getProjectComponent — auth error
// -------------------------------------------------------------------------
diff --git a/external-service-ocp/pom.xml b/external-service-ocp/pom.xml
index befcf6f6..a8e855bd 100644
--- a/external-service-ocp/pom.xml
+++ b/external-service-ocp/pom.xml
@@ -6,7 +6,7 @@
org.opendevstack.apiservice
devstack-api-service
- 0.0.3
+ 0.0.4
external-service-ocp
diff --git a/external-service-projects-info-service/pom.xml b/external-service-projects-info-service/pom.xml
index 76a7779b..b30c6d06 100644
--- a/external-service-projects-info-service/pom.xml
+++ b/external-service-projects-info-service/pom.xml
@@ -6,7 +6,7 @@
org.opendevstack.apiservice
devstack-api-service
- 0.0.3
+ 0.0.4
external-service-projects-info-service
diff --git a/external-service-uipath/pom.xml b/external-service-uipath/pom.xml
index b2a99254..f4bf51b9 100644
--- a/external-service-uipath/pom.xml
+++ b/external-service-uipath/pom.xml
@@ -6,7 +6,7 @@
org.opendevstack.apiservice
devstack-api-service
- 0.0.3
+ 0.0.4
external-service-uipath
diff --git a/external-service-webhookproxy/pom.xml b/external-service-webhookproxy/pom.xml
index 94c8ec1e..7d43a4f1 100644
--- a/external-service-webhookproxy/pom.xml
+++ b/external-service-webhookproxy/pom.xml
@@ -6,7 +6,7 @@
org.opendevstack.apiservice
devstack-api-service
- 0.0.3
+ 0.0.4
external-service-webhookproxy
diff --git a/persistence/pom.xml b/persistence/pom.xml
index 92dc1e5e..e6faef4b 100644
--- a/persistence/pom.xml
+++ b/persistence/pom.xml
@@ -6,7 +6,7 @@
org.opendevstack.apiservice
devstack-api-service
- 0.0.3
+ 0.0.4
persistence
diff --git a/persistence/src/main/resources/db/changelog/migrations/007_add_marketplace_metrics_apis.sql b/persistence/src/main/resources/db/changelog/migrations/007_add_marketplace_metrics_apis.sql
new file mode 100644
index 00000000..db40a3b3
--- /dev/null
+++ b/persistence/src/main/resources/db/changelog/migrations/007_add_marketplace_metrics_apis.sql
@@ -0,0 +1,12 @@
+--liquibase formatted sql
+
+-- ============================================================================
+-- changeset ods:010-seed-marketplace-metrics-api-definitions
+-- Description: Seed API definitions for Marketplace Metrics API.
+-- ============================================================================
+INSERT INTO api_definitions (api_id, name, base_path, version, auth_types, is_public, enabled)
+VALUES
+ ('marketplace-metrics-v1', 'Marketplace Metrics API v1', 'projects/metrics/marketplace/*', 'v1', ARRAY['CLIENT_CREDENTIALS'], FALSE, TRUE)
+ON CONFLICT (api_id) DO NOTHING;
+
+--rollback DELETE FROM api_definitions WHERE api_id IN ('marketplace-metrics-v1');
diff --git a/pom.xml b/pom.xml
index f14bc6df..25a21cbe 100644
--- a/pom.xml
+++ b/pom.xml
@@ -14,7 +14,7 @@
org.opendevstack.apiservice
devstack-api-service
Devstack API Service
- 0.0.3
+ 0.0.4
21
@@ -58,6 +58,7 @@
external-service-marketplace
external-service-webhookproxy
service-projects
+ api-marketplace-metrics
api-project-users
api-project-platform
api-project
diff --git a/service-projects/pom.xml b/service-projects/pom.xml
index f391ffee..c5faf360 100644
--- a/service-projects/pom.xml
+++ b/service-projects/pom.xml
@@ -6,7 +6,7 @@
org.opendevstack.apiservice
devstack-api-service
- 0.0.3
+ 0.0.4
service-projects