Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
636cc1c
Add marketplace api client.
Apr 14, 2026
27682ee
Add marketplace api client.
Apr 14, 2026
2c9e956
Remove unused var.
Apr 14, 2026
6ee3a28
Fix sonarqube complaints.
Apr 14, 2026
cf25bfb
Add delete component functionality - not finished.
Apr 21, 2026
3abe777
Merge branch 'develop' into feature/real-marketplace-components-api-u…
Apr 21, 2026
0e72bc9
Add delete component functionality - not finished.
Apr 21, 2026
4138b18
Implement component existence check and exception handling for duplic…
angelmp01 Apr 22, 2026
911780e
Add API endpoint to retrieve extended information of a project compon…
angelmp01 Apr 27, 2026
3d708a6
Update getComponent to call the new endpoint.
Apr 29, 2026
05fa84f
Implement On-Behalf-Of (OBO) token exchange functionality and enhance…
angelmp01 Apr 29, 2026
db955b7
Fix github comments.
Apr 29, 2026
05a1abf
Refactor exception handling in getProjectComponent to use ComponentNo…
angelmp01 Apr 29, 2026
3a7c372
Add ComponentBadRequestException and enhance exception handling for b…
angelmp01 Apr 29, 2026
2febcc7
Remove unused workflow, odsNamespace, and quickstarterRepository fiel…
angelmp01 Apr 29, 2026
5dff575
Fix test.
Apr 29, 2026
d899671
Merge remote-tracking branch 'origin/feature/real-marketplace-compone…
Apr 29, 2026
14c7d6b
Add delete service and controller.
May 5, 2026
d44279d
Add tests.
May 5, 2026
b9bec95
Update delete with latest API changes.
May 6, 2026
31139b6
Update delete with latest API changes.
May 6, 2026
937171f
Merge branch 'develop' into feature/marketplace-deletion
May 6, 2026
ad2e7fa
Fixes after the merge with develop.
May 6, 2026
c95e565
Fix test.
May 6, 2026
84e6fbf
Update log message.
May 7, 2026
b01b11c
Fix code analysis findings.
May 7, 2026
0d678e6
Fix code analysis findings.
May 7, 2026
5dc0734
Fix PR review findings.
May 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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/pub/v1
variables:
baseurl:
default: localhost:8080
description: Development environment
tags:
- name: Project Components Internal
description: API for managing project components with extended methods

paths:
/projects/{projectId}/components/{componentId}:
delete:
tags:
- Project Components Internal
summary: Delete component information
operationId: deleteProjectComponent
description: Deletes a specific component
parameters:
- name: projectId
Comment thread
valituguran marked this conversation as resolved.
in: path
required: true
description: Project key
schema:
type: string
pattern: "^[A-Z]{2}[A-Z0-9]{1,8}$"
minLength: 3
maxLength: 10
- name: componentId
in: path
required: true
description: Component id
schema:
type: string
minLength: 2
maxLength: 52
pattern: "^[a-z]+(-?[a-z0-9]+)*$"
responses:
"204":
description: Project component properly deleted or no component found for the provided componentId.
"400":
description: Bad request.
"401":
description: Invalid credentials on the request.
"403":
description: Insufficient permissions for the client to access the resource.
"500":
description: Server error.

components:
securitySchemes:
basicAuth:
type: http
scheme: basic
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT

29 changes: 29 additions & 0 deletions api-project-component-v0/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,35 @@
</configOptions>
</configuration>
</execution>
<execution>
<id>generate-api-project-component-extended</id>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<generatorName>spring</generatorName>
<output>${project.basedir}</output>
<library>spring-boot</library>
<inputSpec>${project.basedir}/openapi/api-project-component-v0-internal.yaml</inputSpec>
<apiPackage>org.opendevstack.apiservice.project.api</apiPackage>
<modelPackage>org.opendevstack.apiservice.project.model</modelPackage>
<invokerPackage>org.opendevstack.apiservice.project</invokerPackage>
<skipOverwrite>false</skipOverwrite>
<generateApiTests>false</generateApiTests>
<generateModelTests>false</generateModelTests>
<generateApiDocumentation>false</generateApiDocumentation>
<generateModelDocumentation>false</generateModelDocumentation>
<generateSupportingFiles>false</generateSupportingFiles>
<configOptions>
<interfaceOnly>true</interfaceOnly>
<useSpringBoot3>true</useSpringBoot3>
<documentationProvider>springdoc</documentationProvider>
<skipDefaultInterface>true</skipDefaultInterface>
<hideGenerationTimestamp>true</hideGenerationTimestamp>
<useTags>true</useTags>
</configOptions>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package org.opendevstack.apiservice.project.controller;

import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.opendevstack.apiservice.project.api.ProjectComponentsInternalApi;
import org.opendevstack.apiservice.project.facade.ComponentsFacade;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@AllArgsConstructor
@Slf4j
@RequestMapping(ProjectComponentsInternalController.API_BASE_PATH)
public class ProjectComponentsInternalController implements ProjectComponentsInternalApi {

public static final String API_BASE_PATH = "/api/pub/v1";

private final ComponentsFacade componentsFacade;

@Override
public ResponseEntity<Void> deleteProjectComponent(String projectId, String componentId) {
componentsFacade.deleteProjectComponent(projectId, componentId);
log.info("Deleted component with id '{}' for project '{}'", componentId, projectId);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException;
import org.opendevstack.apiservice.project.exception.ComponentBadRequestException;
import org.opendevstack.apiservice.project.exception.ComponentCreationException;
import org.opendevstack.apiservice.project.exception.ComponentDeletionException;
import org.opendevstack.apiservice.project.exception.ComponentErrorKey;
import org.opendevstack.apiservice.project.exception.ComponentNotFoundException;
import org.opendevstack.apiservice.project.exception.ComponentRetrievalException;
import org.opendevstack.apiservice.project.model.Component;
import org.opendevstack.apiservice.project.model.CreateComponentResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
Expand Down Expand Up @@ -142,6 +142,16 @@ public ResponseEntity<CreateComponentResponse> handleComponentCreationException(
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}

@ExceptionHandler(ComponentDeletionException.class)
public ResponseEntity<Void> handleComponentDeletionException(
ComponentDeletionException ex,
HttpServletRequest request) {

log.error("Component deletion failed: {}", ex.getMessage(), ex);

return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null);
Comment thread
valituguran marked this conversation as resolved.
}

@ExceptionHandler(ComponentRetrievalException.class)
public ResponseEntity<CreateComponentResponse> handleComponentRetrievalException(
ComponentRetrievalException ex,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.opendevstack.apiservice.project.exception;

public class ComponentDeletionException extends RuntimeException {

public ComponentDeletionException(String message) {
super(message);
}

public ComponentDeletionException(String message, Exception e) {
super(message, e);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException;
import org.opendevstack.apiservice.project.exception.ComponentBadRequestException;
import org.opendevstack.apiservice.project.exception.ComponentCreationException;
import org.opendevstack.apiservice.project.exception.ComponentDeletionException;
import org.opendevstack.apiservice.project.exception.ComponentNotFoundException;
import org.opendevstack.apiservice.project.exception.ComponentRetrievalException;
import org.opendevstack.apiservice.project.mapper.MarketplaceMapper;
Expand Down Expand Up @@ -144,13 +145,34 @@ private String extractHttpErrorMessage(Throwable throwable) {
return throwable.getMessage();
}

public Boolean deleteProjectComponent(String projectId, String componentId) {
public void deleteProjectComponent(String projectId, String componentId) {
try {
return marketplaceExternalService.deleteProjectComponent(projectId, componentId);
marketplaceExternalService.deleteProjectComponent(projectId, componentId);
log.info("Successfully deleted component '{}' for project '{}'", componentId, projectId);
} catch (MarketplaceException e) {
log.error("Failed to delete component with id {} for project with id {}", componentId, projectId, e);
return false;
log.error("Failed to delete component '{}' for project '{}': {}", componentId, projectId, e.getMessage(), e);
// Check if it's an access denied error
if (isAccessDeniedCause(e)) {
throw new ComponentDeletionException(
String.format("Access denied when deleting component '%s' from project '%s'", componentId, projectId), e);
}
// Generic deletion failure
throw new ComponentDeletionException(
String.format("Failed to delete component '%s' for project '%s': %s", componentId, projectId, e.getMessage()), e
);
}
}

private boolean isAccessDeniedCause(Throwable throwable) {
Throwable current = throwable;
while (current != null) {
if (current instanceof HttpClientErrorException.Unauthorized
|| current instanceof HttpClientErrorException.Forbidden) {
return true;
}
current = current.getCause();
}
return false;
}

public boolean registerProjectComponent(String projectId, String componentId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,5 @@ void get_project_component_throws_not_found_when_component_does_not_exist() thro
.hasMessage("Component '" + componentId + "' not found for project 'projectId'");
verify(componentsFacade).getProjectComponent(projectId, componentId);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package org.opendevstack.apiservice.project.controller;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.opendevstack.apiservice.project.exception.ComponentDeletionException;
import org.opendevstack.apiservice.project.facade.ComponentsFacade;
Comment thread
valituguran marked this conversation as resolved.
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;

class ProjectComponentsInternalControllerTest {

@Mock
private ComponentsFacade componentsFacade;

private ProjectComponentsInternalController projectComponentsInternalController;

private AutoCloseable openMocks;

@BeforeEach
void setup() {
openMocks = MockitoAnnotations.openMocks(this);
projectComponentsInternalController = new ProjectComponentsInternalController(componentsFacade);
}

@AfterEach
void tearDown() throws Exception {
openMocks.close();
}

@Test
void delete_project_component_returns_no_content_when_component_delete_works() {
String projectId = "projectId";
String componentId = "test-component-delete";

ResponseEntity<Void> response = projectComponentsInternalController.deleteProjectComponent(projectId, componentId);

assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
assertThat(response.getBody()).isNull();
verify(componentsFacade).deleteProjectComponent(projectId, componentId);
}

@Test
void delete_project_component_throws_exception_when_component_delete_api_throws_exception() {
String projectId = "projectId";
String componentId = "test-component-delete";

doThrow(new ComponentDeletionException("test exception"))
.when(componentsFacade).deleteProjectComponent(anyString(), anyString());

assertThrows(ComponentDeletionException.class, () ->
projectComponentsInternalController.deleteProjectComponent(projectId, componentId)
);
verify(componentsFacade).deleteProjectComponent(projectId, componentId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService;
import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException;
import org.opendevstack.apiservice.project.exception.ComponentCreationException;
import org.opendevstack.apiservice.project.exception.ComponentDeletionException;
import org.opendevstack.apiservice.project.exception.ComponentNotFoundException;
import org.opendevstack.apiservice.project.mapper.MarketplaceMapper;
import org.opendevstack.apiservice.project.model.Component;
Expand All @@ -21,11 +22,12 @@
import org.springframework.http.HttpStatus;
import org.springframework.web.client.HttpClientErrorException;

import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestCatalogItem;
Expand Down Expand Up @@ -120,4 +122,23 @@ void create_project_component_throws_already_exists_when_marketplace_returns_con
.hasMessage("This component name already exists, please choose another name.");
verify(marketplaceExternalService).provisionProjectComponent(eq("testProject"), anyList());
}


@Test
void delete_project_component_ends_successfully_for_existing_component() throws MarketplaceException {
componentsFacade.deleteProjectComponent("testProject", "testComponent");

verify(marketplaceExternalService).deleteProjectComponent("testProject", "testComponent");
}

@Test
void delete_project_component_throws_component_deletion_exception_when_marketplace_exception_is_thrown() throws MarketplaceException {
doThrow(new MarketplaceException("Test exception"))
.when(marketplaceExternalService).deleteProjectComponent("testProject", "testComponent");

assertThatThrownBy(() -> componentsFacade.deleteProjectComponent("testProject", "testComponent"))
.isInstanceOf(ComponentDeletionException.class)
.hasMessageContaining("Test exception");
verify(marketplaceExternalService).deleteProjectComponent("testProject", "testComponent");
Comment thread
valituguran marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ paths:
schema:
$ref: '#/components/schemas/ProvisioningDeleteRequest'
responses:
"200":
"204":
description: Project component properly deleted.
"400":
description: Bad request.
Expand Down
2 changes: 1 addition & 1 deletion external-service-marketplace/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@
<goal>generate</goal>
</goals>
<configuration>
<openapiNormalizer>FILTER=operationId:triggerProvisionAction|notifyProvisioningStatusUpdate|createIncident|getProvisionerHealth</openapiNormalizer>
<openapiNormalizer>FILTER=operationId:triggerProvisionAction|notifyProvisioningStatusUpdate|deleteProvisioningStatus|getProvisionerHealth</openapiNormalizer>
<generatorName>java</generatorName>
<output>${project.basedir}</output>
<library>resttemplate</library>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ public MarketplaceApiClient(String instanceName, MarketplaceInstanceConfig confi
// Initialize the generated ApiClient
this.apiClient = new ApiClient(restTemplate);

if (config.getUsername() != null && config.getPassword() != null) {
this.apiClient.setUsername(config.getUsername());
this.apiClient.setPassword(config.getPassword());
log.info("MarketplaceApiClient for instance '{}' uses basic authentication", instanceName);
}

log.info("MarketplaceApiClient initialized for instance '{}'", instanceName);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public class MarketplaceApiClientFactory {
* @param restTemplateBuilder RestTemplate builder for creating HTTP clients
*/
public MarketplaceApiClientFactory(MarketplaceServiceConfig configuration,
RestTemplateBuilder restTemplateBuilder) {
RestTemplateBuilder restTemplateBuilder) {
this.configuration = configuration;
this.restTemplateBuilder = restTemplateBuilder;

Expand Down Expand Up @@ -70,7 +70,7 @@ public String getDefaultInstanceName() throws MarketplaceException {

/**
* Get a {@link MarketplaceApiClient} for a specific instance.
* If {@code instanceName} is {@code null} or blank, this method will throw a {@link MarketplaceException}
* If {@code instanceName} is {@code null} or blank, this method will throw a {@link MarketplaceException}
* to avoid ambiguity. The caller should explicitly call {@link #getClient()} to get the default instance client
* in that case.
*
Expand Down Expand Up @@ -212,4 +212,4 @@ protected void prepareConnection(HttpURLConnection connection, String httpMethod
}
};
}
}
}
Loading
Loading