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 + + + ${project.build.directory}/generated-sources/openapi + + + 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 ${project.basedir}/target/generated-sources/openapi resttemplate @@ -186,7 +186,7 @@ generate - FILTER=operationId:triggerProvisionAction|notifyProvisioningStatusUpdate|deleteProjectComponent|getProjectComponentProvisionStatusById|getProvisionerHealth + FILTER=operationId:triggerProvisionAction|notifyProvisioningStatusUpdate|deleteProjectComponent|getProjectComponentProvisionStatusById|getAllProjectComponents|getProvisionerHealth java ${project.basedir}/target/generated-sources/openapi 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