diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/consul/ConfigurationPropertiesConstants.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/consul/ConfigurationPropertiesConstants.java index ab868032..5242f000 100644 --- a/src/main/java/org/qubership/integration/platform/runtime/catalog/consul/ConfigurationPropertiesConstants.java +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/consul/ConfigurationPropertiesConstants.java @@ -50,6 +50,7 @@ public class ConfigurationPropertiesConstants { public static final String GRPC_SENDER_ELEMENT = "grpc-sender"; public static final String CHAIN_CALL_2_ELEMENT = "chain-call-2"; public static final String HTTP_TRIGGER_ELEMENT = "http-trigger"; + public static final String MCP_TRIGGER_ELEMENT = "mcp-trigger"; public static final String SERVICE_CALL_ELEMENT = "service-call"; public static final String CHAIN_CALL_PROPERTY_OPTION = "chain-call"; public static final String HTTP_TRIGGER_FAILURE_HANDLER_ACTION = "handleChainFailureAction"; diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/cr/sources/builders/xml/beans/builders/element/CommonBeansBuilder.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/cr/sources/builders/xml/beans/builders/element/CommonBeansBuilder.java index bd6c45e7..4427eff9 100644 --- a/src/main/java/org/qubership/integration/platform/runtime/catalog/cr/sources/builders/xml/beans/builders/element/CommonBeansBuilder.java +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/cr/sources/builders/xml/beans/builders/element/CommonBeansBuilder.java @@ -46,6 +46,10 @@ public void build( streamWriter.writeAttribute("key", "id"); streamWriter.writeAttribute("value", element.getOriginalId()); + streamWriter.writeEmptyElement("property"); + streamWriter.writeAttribute("key", "snapshotElementId"); + streamWriter.writeAttribute("value", element.getId()); + streamWriter.writeEmptyElement("property"); streamWriter.writeAttribute("key", "name"); streamWriter.writeAttribute("value", element.getName()); diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/cr/sources/builders/xml/beans/builders/element/McpTriggerBeansBuilder.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/cr/sources/builders/xml/beans/builders/element/McpTriggerBeansBuilder.java new file mode 100644 index 00000000..a5f32315 --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/cr/sources/builders/xml/beans/builders/element/McpTriggerBeansBuilder.java @@ -0,0 +1,55 @@ +package org.qubership.integration.platform.runtime.catalog.cr.sources.builders.xml.beans.builders.element; + +import org.codehaus.stax2.XMLStreamWriter2; +import org.qubership.integration.platform.runtime.catalog.cr.sources.SourceBuilderContext; +import org.qubership.integration.platform.runtime.catalog.cr.sources.builders.xml.beans.ElementBeansBuilder; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.chain.element.ChainElement; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +import static org.qubership.integration.platform.runtime.catalog.consul.ConfigurationPropertiesConstants.MCP_TRIGGER_ELEMENT; + +@Component +public class McpTriggerBeansBuilder implements ElementBeansBuilder { + @Override + public boolean applicableTo(ChainElement element) { + String type = element.getType(); + return MCP_TRIGGER_ELEMENT.equals(type); + } + + @Override + public void build(XMLStreamWriter2 streamWriter, ChainElement element, SourceBuilderContext context) throws Exception { + streamWriter.writeStartElement("bean"); + streamWriter.writeAttribute("name", "McpTriggerInfo-" + element.getId()); + streamWriter.writeAttribute("type", "org.qubership.integration.platform.engine.metadata.McpTriggerInfo"); + + streamWriter.writeStartElement("properties"); + + Collection propertyNames = List.of( + "name", + "title", + "description", + "inputSchema", + "outputSchema", + "readOnly", + "destructive", + "idempotent", + "openWorld", + "requiresLocal" + ); + + for (String propertyName : propertyNames) { + streamWriter.writeEmptyElement("property"); + streamWriter.writeAttribute("key", propertyName); + streamWriter.writeAttribute("value", Optional.ofNullable(element.getProperties().get(propertyName)) + .map(String::valueOf) + .orElse("")); + } + + streamWriter.writeEndElement(); + streamWriter.writeEndElement(); + } +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/model/constant/CamelNames.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/model/constant/CamelNames.java index dde05aac..cbb8c60f 100644 --- a/src/main/java/org/qubership/integration/platform/runtime/catalog/model/constant/CamelNames.java +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/model/constant/CamelNames.java @@ -132,6 +132,8 @@ public final class CamelNames { public static final String MAAS_CLASSIFIER_TENANT_ENABLED_CAMEL_NAME = "maas.classifier.tenantEnabled"; public static final String MAAS_CLASSIFIER_TENANT_ID_CAMEL_NAME = "maas.classifier.tenantId"; + public static final String MCP_SERVICE_IDS = "mcpServiceIds"; + private CamelNames() { } diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/model/exportimport/instructions/GeneralImportInstructionsConfig.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/model/exportimport/instructions/GeneralImportInstructionsConfig.java index 5d65e9a5..74988cbc 100644 --- a/src/main/java/org/qubership/integration/platform/runtime/catalog/model/exportimport/instructions/GeneralImportInstructionsConfig.java +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/model/exportimport/instructions/GeneralImportInstructionsConfig.java @@ -47,6 +47,9 @@ public class GeneralImportInstructionsConfig { private ImportInstructionsConfig contextServices = new ImportInstructionsConfig(); @Valid @Builder.Default + private ImportInstructionsConfig mcpServices = new ImportInstructionsConfig(); + @Valid + @Builder.Default @JsonIgnoreProperties(value = "ignore") private ImportInstructionsConfig specificationGroups = new ImportInstructionsConfig(); @Valid diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/model/exportimport/system/MCPServiceContentDto.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/model/exportimport/system/MCPServiceContentDto.java new file mode 100644 index 00000000..93046160 --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/model/exportimport/system/MCPServiceContentDto.java @@ -0,0 +1,34 @@ +package org.qubership.integration.platform.runtime.catalog.model.exportimport.system; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.User; + +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +@SuperBuilder +@Jacksonized +@JsonInclude(JsonInclude.Include.NON_EMPTY) +@JsonIgnoreProperties(ignoreUnknown = true) +public class MCPServiceContentDto { + private String description; + private String identifier; + private String instructions; + private Timestamp createdWhen; + private Timestamp modifiedWhen; + private User createdBy; + private User modifiedBy; + private String migrations; + + @Builder.Default + private List labels = new ArrayList<>(); +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/model/exportimport/system/MCPServiceDto.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/model/exportimport/system/MCPServiceDto.java new file mode 100644 index 00000000..4c29f79a --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/model/exportimport/system/MCPServiceDto.java @@ -0,0 +1,27 @@ +package org.qubership.integration.platform.runtime.catalog.model.exportimport.system; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +import java.net.URI; + +@Getter +@Setter +@SuperBuilder +@Jacksonized +@JsonInclude(JsonInclude.Include.NON_EMPTY) +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonPropertyOrder({ "id", "schema", "name", "content" }) +public class MCPServiceDto { + @JsonProperty(value = "$schema", index = 0) + private URI schema; + private String id; + private String name; + MCPServiceContentDto content; +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/model/filter/FilterFeature.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/model/filter/FilterFeature.java index fb67390a..746ab99e 100644 --- a/src/main/java/org/qubership/integration/platform/runtime/catalog/model/filter/FilterFeature.java +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/model/filter/FilterFeature.java @@ -58,5 +58,7 @@ public enum FilterFeature { SESSION_DURATION, EXCHANGE_DURATION, MAIN_THREAD, - POD_IP + POD_IP, + IDENTIFIER, + INSTRUCTIONS, } diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/model/system/exportimport/ExportableObject.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/model/system/exportimport/ExportableObject.java index 82d4af8c..e2a1337d 100644 --- a/src/main/java/org/qubership/integration/platform/runtime/catalog/model/system/exportimport/ExportableObject.java +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/model/system/exportimport/ExportableObject.java @@ -22,5 +22,7 @@ import java.util.zip.ZipOutputStream; public interface ExportableObject { + String getId(); + void accept(ExportableObjectWriterVisitor visitor, ZipOutputStream zipOut, String entryPath) throws IOException; } diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/model/system/exportimport/ExportedMCPSystemObject.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/model/system/exportimport/ExportedMCPSystemObject.java new file mode 100644 index 00000000..feb33e53 --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/model/system/exportimport/ExportedMCPSystemObject.java @@ -0,0 +1,18 @@ +package org.qubership.integration.platform.runtime.catalog.model.system.exportimport; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.serializer.ExportableObjectWriterVisitor; + +import java.io.IOException; +import java.util.zip.ZipOutputStream; + +public class ExportedMCPSystemObject extends ExportedSystemObject { + public ExportedMCPSystemObject(String id, ObjectNode objectNode) { + super(id, objectNode); + } + + @Override + public void accept(ExportableObjectWriterVisitor visitor, ZipOutputStream zipOut, String entryPath) throws IOException { + visitor.visit(this, zipOut, entryPath); + } +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/persistence/configs/entity/actionlog/EntityType.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/persistence/configs/entity/actionlog/EntityType.java index 5bc256d1..b716ea22 100644 --- a/src/main/java/org/qubership/integration/platform/runtime/catalog/persistence/configs/entity/actionlog/EntityType.java +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/persistence/configs/entity/actionlog/EntityType.java @@ -33,6 +33,7 @@ public enum EntityType { CHAIN_RUNTIME_PROPERTIES, DATABASE_SYSTEM, //removed databases in 24.3 CONTEXT_SYSTEM, + MCP_SYSTEM, DATABASE_SCRIPT, //This types remained to avoid error with old actions(in action log) with databases SERVICE_DISCOVERY, EXTERNAL_SERVICE, diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/persistence/configs/entity/mcp/MCPSystem.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/persistence/configs/entity/mcp/MCPSystem.java new file mode 100644 index 00000000..2850a749 --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/persistence/configs/entity/mcp/MCPSystem.java @@ -0,0 +1,61 @@ +package org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.mcp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Transient; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.FieldNameConstants; +import lombok.experimental.SuperBuilder; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.chain.Chain; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.system.AbstractSystemEntity; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import static jakarta.persistence.CascadeType.*; + +@Getter +@Setter +@SuperBuilder +@NoArgsConstructor +@FieldNameConstants +@Entity(name = "mcp_systems") +public class MCPSystem extends AbstractSystemEntity { + @Column + private String identifier; + + @Column + private String instructions; + + @Builder.Default + @OneToMany(mappedBy = "system", + orphanRemoval = true, + cascade = {PERSIST, REMOVE, MERGE} + ) + private Set labels = new LinkedHashSet<>(); + + @Transient + private List chains; + + @Override + public boolean equals(Object o) { + if (!super.equals(o)) { + return false; + } + if (!(o instanceof MCPSystem mcpSystem)) { + return false; + } + return Objects.equals(identifier, mcpSystem.identifier) && Objects.equals(instructions, mcpSystem.instructions); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), getIdentifier(), getInstructions(), getChains()); + } +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/persistence/configs/entity/mcp/MCPSystemLabel.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/persistence/configs/entity/mcp/MCPSystemLabel.java new file mode 100644 index 00000000..ea622656 --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/persistence/configs/entity/mcp/MCPSystemLabel.java @@ -0,0 +1,54 @@ +package org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.mcp; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import lombok.extern.slf4j.Slf4j; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.AbstractLabel; + +import java.util.Objects; + +@Getter +@Setter +@Slf4j +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@Entity(name = "mcp_system_labels") +public class MCPSystemLabel extends AbstractLabel { + @JsonIgnore + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mcp_system_id") + private MCPSystem system; + + public MCPSystemLabel(String name, MCPSystem system) { + super(name); + this.system = system; + } + + public MCPSystemLabel(String name, boolean technical, MCPSystem system) { + super(name, technical); + this.system = system; + } + + @Override + public boolean equals(Object o) { + if (!super.equals(o)) { + return false; + } + MCPSystemLabel that = (MCPSystemLabel) o; + return Objects.equals(getSystem(), that.getSystem()); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), getSystem()); + } +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/persistence/configs/repository/chain/ChainRepository.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/persistence/configs/repository/chain/ChainRepository.java index 10f4476d..a2736c6f 100644 --- a/src/main/java/org/qubership/integration/platform/runtime/catalog/persistence/configs/repository/chain/ChainRepository.java +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/persistence/configs/repository/chain/ChainRepository.java @@ -86,6 +86,30 @@ WITH RECURSIVE parent_folders AS ( ) List findAllChainsInFolders(List folderIds); + @Query( + nativeQuery = true, + value = """ + select distinct on (chain.id) chain.* + from catalog.chains chain + inner join catalog.elements element + on element.chain_id = chain.id + where jsonb_extract_path_text(element.properties, :property) = :value + """ + ) + List findChainsWithElementPropertyValue(String property, String value); + + @Query( + nativeQuery = true, + value = """ + select distinct on (chain.id) chain.* + from catalog.chains chain + inner join catalog.elements element + on element.chain_id = chain.id + where jsonb_exists(jsonb_extract_path(element.properties, :property), :value) + """ + ) + List findChainsWithElementPropertyContainsValue(String property, String value); + @Query( nativeQuery = true, value = """ diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/persistence/configs/repository/mcp/MCPSystemRepository.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/persistence/configs/repository/mcp/MCPSystemRepository.java new file mode 100644 index 00000000..9405e086 --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/persistence/configs/repository/mcp/MCPSystemRepository.java @@ -0,0 +1,9 @@ +package org.qubership.integration.platform.runtime.catalog.persistence.configs.repository.mcp; + +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.mcp.MCPSystem; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface MCPSystemRepository extends JpaRepository, JpaSpecificationExecutor { + +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/rest/v1/controller/MCPSystemController.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/rest/v1/controller/MCPSystemController.java new file mode 100644 index 00000000..ea5c35a8 --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/rest/v1/controller/MCPSystemController.java @@ -0,0 +1,171 @@ +package org.qubership.integration.platform.runtime.catalog.rest.v1.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.extensions.Extension; +import io.swagger.v3.oas.annotations.extensions.ExtensionProperty; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.qubership.integration.platform.runtime.catalog.model.exportimport.system.ImportSystemResult; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.mcp.MCPSystem; +import org.qubership.integration.platform.runtime.catalog.rest.v1.dto.system.imports.ImportSystemStatus; +import org.qubership.integration.platform.runtime.catalog.rest.v1.dto.system.mcp.MCPSystemFilterRequestDTO; +import org.qubership.integration.platform.runtime.catalog.rest.v1.dto.system.mcp.MCPSystemRequestDTO; +import org.qubership.integration.platform.runtime.catalog.rest.v1.dto.system.mcp.MCPSystemResponseDTO; +import org.qubership.integration.platform.runtime.catalog.rest.v1.mapper.MCPSystemMapper; +import org.qubership.integration.platform.runtime.catalog.service.MCPSystemService; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.MCPSystemImportExportService; +import org.qubership.integration.platform.runtime.catalog.util.ExportImportUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +import static java.util.Objects.isNull; + +@Slf4j +@ComponentScan +@RestController +@CrossOrigin(origins = "*") +@RequestMapping(value = "/v1/catalog/mcp-system", produces = MediaType.APPLICATION_JSON_VALUE) +@Tag(name = "mcp-system-controller", description = "MCP System Controller") +public class MCPSystemController { + private final MCPSystemService mcpSystemService; + private final MCPSystemMapper mcpSystemMapper; + private final MCPSystemImportExportService mcpSystemImportExportService; + + @Autowired + public MCPSystemController( + MCPSystemService mcpSystemService, + MCPSystemMapper mcpSystemMapper, + MCPSystemImportExportService mcpSystemImportExportService + ) { + this.mcpSystemService = mcpSystemService; + this.mcpSystemMapper = mcpSystemMapper; + this.mcpSystemImportExportService = mcpSystemImportExportService; + } + + @GetMapping + @Operation(description = "Get all MCP systems") + public ResponseEntity> getAll( + @RequestParam(name = "withChains", defaultValue = "false") + boolean withChains + ) { + log.debug("Request to get all MCP systems"); + List systems = mcpSystemService.findAll(withChains); + List dtos = mcpSystemMapper.toResponseDtos(systems); + return ResponseEntity.ok(dtos); + } + + @GetMapping("/{id}") + @Operation(description = "Get MCP system by ID") + public ResponseEntity getById( + @PathVariable String id + ) { + log.debug("Request to get MCP system by ID: {}", id); + return mcpSystemService.findById(id).map(system -> { + MCPSystemResponseDTO dto = mcpSystemMapper.toResponseDto(system); + return ResponseEntity.ok(dto); + }).orElse(ResponseEntity.notFound().build()); + } + + @PostMapping + @Operation(description = "Create MCP system") + public ResponseEntity create( + @RequestBody MCPSystemRequestDTO requestDTO + ) { + log.debug("Request to create MCP system: {}", requestDTO); + MCPSystem system = mcpSystemService.create(requestDTO); + MCPSystemResponseDTO dto = mcpSystemMapper.toResponseDto(system); + return ResponseEntity.status(HttpStatus.CREATED).body(dto); + } + + @PutMapping("/{id}") + @Operation(description = "Update MCP system") + public ResponseEntity update( + @PathVariable String id, + @RequestBody MCPSystemRequestDTO requestDTO + ) { + log.debug("Request to update MCP system: {}", requestDTO); + MCPSystem system = mcpSystemService.update(id, requestDTO); + MCPSystemResponseDTO dto = mcpSystemMapper.toResponseDto(system); + return ResponseEntity.ok(dto); + } + + @DeleteMapping("/{id}") + @Operation(description = "Delete MCP system") + public ResponseEntity delete(@PathVariable String id) { + log.debug("Request to delete MCP system: {}", id); + mcpSystemService.deleteById(id); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/filter") + @Operation(description = "Filter MCP systems") + public ResponseEntity> filter( + @RequestBody MCPSystemFilterRequestDTO requestDTO + ) { + log.debug("Request to filter MCP systems: {}", requestDTO); + List systems = mcpSystemService.filter(requestDTO.getSearchString(), requestDTO.getFilters()); + List dtos = mcpSystemMapper.toResponseDtos(systems); + return ResponseEntity.ok(dtos); + } + + @PostMapping(value = "/export", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) + @Operation(description = "Export MCP services") + public ResponseEntity export( + @RequestBody List ids + ) { + byte[] data = mcpSystemImportExportService.export(ids); + if (isNull(data)) { + return ResponseEntity.noContent().build(); + } + + return ExportImportUtils.convertFileToResponse(data, ExportImportUtils.generateArchiveExportName()); + } + + @PostMapping("/import") + @Operation( + extensions = @Extension( + properties = {@ExtensionProperty(name = "x-api-kind", value = "bwc")} + ), + description = "Import MCP services from a file" + ) + public ResponseEntity> importSystems( + @RequestParam("file") + @Parameter(description = "File") + MultipartFile file, + + @RequestParam(required = false) + @Parameter(description = "List of system IDs") + List ids + ) { + List result = mcpSystemImportExportService.importSystems(file, ids); + if (result.isEmpty()) { + return ResponseEntity.noContent().build(); + } else { + HttpStatus responseCode = result.stream().anyMatch(dto -> dto.getStatus().equals(ImportSystemStatus.ERROR)) + ? HttpStatus.MULTI_STATUS + : HttpStatus.OK; + return ResponseEntity.status(responseCode).body(result); + } + } + + @PostMapping("/import/preview") + @Operation(description = "Get preview on what will be imported from file") + public ResponseEntity> getImportPreview( + @RequestParam("file") + @Parameter(description = "File") + MultipartFile file + ) { + List result = mcpSystemImportExportService.getImportPreview(file); + return result.isEmpty() + ? ResponseEntity.noContent().build() + : ResponseEntity.ok().body(result); + } +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/rest/v1/dto/system/mcp/MCPSystemFilterRequestDTO.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/rest/v1/dto/system/mcp/MCPSystemFilterRequestDTO.java new file mode 100644 index 00000000..06230bf5 --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/rest/v1/dto/system/mcp/MCPSystemFilterRequestDTO.java @@ -0,0 +1,17 @@ +package org.qubership.integration.platform.runtime.catalog.rest.v1.dto.system.mcp; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.qubership.integration.platform.runtime.catalog.rest.v1.dto.FilterRequestDTO; + +import java.util.List; + +@Data +@Schema(description = "MCP system filter request object") +public class MCPSystemFilterRequestDTO { + @Schema(description = "Search string") + private String searchString; + + @Schema(description = "Filters") + private List filters; +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/rest/v1/dto/system/mcp/MCPSystemRequestDTO.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/rest/v1/dto/system/mcp/MCPSystemRequestDTO.java new file mode 100644 index 00000000..795bb25d --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/rest/v1/dto/system/mcp/MCPSystemRequestDTO.java @@ -0,0 +1,26 @@ +package org.qubership.integration.platform.runtime.catalog.rest.v1.dto.system.mcp; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.qubership.integration.platform.runtime.catalog.rest.v1.dto.system.SystemLabelDTO; + +import java.util.List; + +@Data +@Schema(description = "MCP Service request object") +public class MCPSystemRequestDTO { + @Schema(description = "Name") + private String name; + + @Schema(description = "description") + private String description; + + @Schema(description = "MCP server name") + private String identifier; + + @Schema(description = "MCP server instructions") + private String instructions; + + @Schema(description = "Labels assigned to the service") + private List labels; +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/rest/v1/dto/system/mcp/MCPSystemResponseDTO.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/rest/v1/dto/system/mcp/MCPSystemResponseDTO.java new file mode 100644 index 00000000..21add3de --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/rest/v1/dto/system/mcp/MCPSystemResponseDTO.java @@ -0,0 +1,27 @@ +package org.qubership.integration.platform.runtime.catalog.rest.v1.dto.system.mcp; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.qubership.integration.platform.runtime.catalog.model.dto.BaseResponse; +import org.qubership.integration.platform.runtime.catalog.model.dto.user.UserDTO; +import org.qubership.integration.platform.runtime.catalog.rest.v1.dto.system.SystemLabelDTO; + +import java.util.List; + +@Data +@Schema(description = "Create MCP Service request object") +public class MCPSystemResponseDTO { + private String id; + private String name; + private String description; + private String identifier; + private String instructions; + private Long createdWhen; + private UserDTO createdBy; + private Long modifiedWhen; + private UserDTO modifiedBy; + private List labels; + + @Schema(description = "List of chains that is using current service") + private List chains; +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/rest/v1/mapper/MCPSystemMapper.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/rest/v1/mapper/MCPSystemMapper.java new file mode 100644 index 00000000..aca7ee1f --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/rest/v1/mapper/MCPSystemMapper.java @@ -0,0 +1,38 @@ +package org.qubership.integration.platform.runtime.catalog.rest.v1.mapper; + +import org.mapstruct.*; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.mcp.MCPSystem; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.mcp.MCPSystemLabel; +import org.qubership.integration.platform.runtime.catalog.rest.v1.dto.system.SystemLabelDTO; +import org.qubership.integration.platform.runtime.catalog.rest.v1.dto.system.mcp.MCPSystemRequestDTO; +import org.qubership.integration.platform.runtime.catalog.rest.v1.dto.system.mcp.MCPSystemResponseDTO; +import org.qubership.integration.platform.runtime.catalog.util.MapperUtils; + +import java.util.List; + +@Mapper( + componentModel = "spring", + nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE, + collectionMappingStrategy = CollectionMappingStrategy.SETTER_PREFERRED, + uses = { + MapperUtils.class + } +) +public interface MCPSystemMapper { + List toResponseDtos(List systems); + + MCPSystemResponseDTO toResponseDto(MCPSystem system); + + @Mapping(target = "labels", ignore = true) + MCPSystem updateWithoutLabels(@MappingTarget MCPSystem contextSystem, MCPSystemRequestDTO request); + + MCPSystemLabel updateLabel(@MappingTarget MCPSystemLabel label, SystemLabelDTO labelDTO); + + MCPSystemLabel asLabel(SystemLabelDTO labelDTO); + + List asLabels(List labelDTOs); + + SystemLabelDTO asLabelDTO(MCPSystemLabel label); + + List asLabelDTOs(List labels); +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/rest/v3/dto/exportimport/ImportPreviewResponse.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/rest/v3/dto/exportimport/ImportPreviewResponse.java index 43af252e..26b1e8dc 100644 --- a/src/main/java/org/qubership/integration/platform/runtime/catalog/rest/v3/dto/exportimport/ImportPreviewResponse.java +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/rest/v3/dto/exportimport/ImportPreviewResponse.java @@ -49,6 +49,9 @@ public class ImportPreviewResponse { @Schema(description = "List of results by each context service") private List contextService = new ArrayList<>(); @Builder.Default + @Schema(description = "List of results by each MCP service") + private List mcpService = new ArrayList<>(); + @Builder.Default @Schema(description = "List of results by each variable") private List variables = new ArrayList<>(); @Schema(description = "Import instructions preview") diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/MCPSystemService.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/MCPSystemService.java new file mode 100644 index 00000000..ddd707d7 --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/MCPSystemService.java @@ -0,0 +1,165 @@ +package org.qubership.integration.platform.runtime.catalog.service; + +import jakarta.persistence.EntityNotFoundException; +import org.qubership.integration.platform.runtime.catalog.exception.exceptions.SystemDeleteException; +import org.qubership.integration.platform.runtime.catalog.model.constant.CamelNames; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.actionlog.ActionLog; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.actionlog.EntityType; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.actionlog.LogOperation; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.chain.Chain; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.mcp.MCPSystem; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.mcp.MCPSystemLabel; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.repository.chain.ChainRepository; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.repository.mcp.MCPSystemRepository; +import org.qubership.integration.platform.runtime.catalog.rest.v1.dto.FilterRequestDTO; +import org.qubership.integration.platform.runtime.catalog.rest.v1.dto.system.mcp.MCPSystemRequestDTO; +import org.qubership.integration.platform.runtime.catalog.rest.v1.mapper.MCPSystemMapper; +import org.qubership.integration.platform.runtime.catalog.service.filter.MCPSystemFilterSpecificationBuilder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@Transactional +public class MCPSystemService { + private final MCPSystemRepository mcpSystemRepository; + private final MCPSystemMapper mcpSystemMapper; + private final ActionsLogService actionLogger; + private final MCPSystemFilterSpecificationBuilder mcpSystemFilterSpecificationBuilder; + private final ChainRepository chainRepository; + + @Autowired + public MCPSystemService( + MCPSystemRepository mcpSystemRepository, + MCPSystemMapper mcpSystemMapper, + ActionsLogService actionLogger, + MCPSystemFilterSpecificationBuilder mcpSystemFilterSpecificationBuilder, + ChainRepository chainRepository + ) { + this.mcpSystemRepository = mcpSystemRepository; + this.mcpSystemMapper = mcpSystemMapper; + this.actionLogger = actionLogger; + this.mcpSystemFilterSpecificationBuilder = mcpSystemFilterSpecificationBuilder; + this.chainRepository = chainRepository; + } + + public List findAll(boolean withChains) { + List systems = mcpSystemRepository.findAll(); + return withChains + ? enrichWithChains(systems) + : systems; + } + + public List findAll() { + return findAll(false); + } + + public List findAllById(List ids) { + return mcpSystemRepository.findAllById(ids); + } + + public Optional findById(String id) { + return mcpSystemRepository.findById(id); + } + + public MCPSystem create(MCPSystemRequestDTO request) { + MCPSystem mcpSystem = new MCPSystem(); + mcpSystem.setName(request.getName()); + mcpSystem.setDescription(request.getDescription()); + mcpSystem.setIdentifier(request.getIdentifier()); + mcpSystem.setInstructions(request.getInstructions()); + return mcpSystemRepository.save(mcpSystem); + } + + public MCPSystem create(MCPSystem system, boolean isImport) { + system = mcpSystemRepository.save(system); + logAction(system, isImport ? LogOperation.IMPORT : LogOperation.CREATE); + return system; + } + + public MCPSystem update(String id, MCPSystemRequestDTO request) { + Optional mcpSystem = mcpSystemRepository.findById(id); + if (mcpSystem.isEmpty()) { + throw new EntityNotFoundException(String.format("MCP system with id %s not found", id)); + } + MCPSystem system = mcpSystem.get(); + MCPSystem updatedSystem = mcpSystemMapper.updateWithoutLabels(system, request); + + List newLabels = request.getLabels().stream().map(mcpSystemMapper::asLabel).toList(); + updateLabels(updatedSystem, updatedSystem.getLabels(), newLabels); + return update(updatedSystem); + } + + public MCPSystem update(MCPSystem system) { + system = mcpSystemRepository.save(system); + logAction(system, LogOperation.UPDATE); + return system; + } + + public void updateLabels( + MCPSystem system, + Collection currentLabels, + Collection newLabels + ) { + Map labelMap = newLabels.stream() + .collect(Collectors.toMap(MCPSystemLabel::getName, Function.identity())); + + currentLabels.removeIf(label -> !labelMap.containsKey(label.getName())); + currentLabels.forEach(label -> { + MCPSystemLabel updatedLabel = labelMap.remove(label.getName()); + label.setTechnical(updatedLabel.isTechnical()); + }); + labelMap.values().forEach(label -> label.setSystem(system)); + currentLabels.addAll(labelMap.values()); + } + + public void deleteById(String id) { + if (isUsedByChain(id)) { + throw new SystemDeleteException("Service used by one or more chains"); + } + mcpSystemRepository.findById(id).ifPresent(system -> { + mcpSystemRepository.delete(system); + logAction(system, LogOperation.DELETE); + }); + } + + public List filter(String searchString, List filters) { + Specification specification = mcpSystemFilterSpecificationBuilder.buildSearchAndFilters(searchString, filters); + return enrichWithChains(mcpSystemRepository.findAll(specification)); + } + + private void logAction(MCPSystem system, LogOperation operation) { + actionLogger.logAction(ActionLog.builder() + .entityType(EntityType.MCP_SYSTEM) + .entityId(system.getId()) + .entityName(system.getName()) + .parentId(null) + .operation(operation) + .build()); + } + + public boolean isUsedByChain(String id) { + return !findChainsByMcpSystemId(id).isEmpty(); + } + + private void enrichWithChains(MCPSystem system) { + system.setChains(findChainsByMcpSystemId(system.getId())); + } + + private List enrichWithChains(List systems) { + systems.forEach(this::enrichWithChains); + return systems; + } + + private List findChainsByMcpSystemId(String mcpSystemId) { + return chainRepository.findChainsWithElementPropertyContainsValue(CamelNames.MCP_SERVICE_IDS, mcpSystemId); + } +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/deployment/properties/builders/McpTriggerPropertiesBuilder.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/deployment/properties/builders/McpTriggerPropertiesBuilder.java new file mode 100644 index 00000000..0e7d83e6 --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/deployment/properties/builders/McpTriggerPropertiesBuilder.java @@ -0,0 +1,43 @@ +package org.qubership.integration.platform.runtime.catalog.service.deployment.properties.builders; + +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.chain.element.ChainElement; +import org.qubership.integration.platform.runtime.catalog.service.deployment.properties.ElementPropertiesBuilder; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.qubership.integration.platform.runtime.catalog.consul.ConfigurationPropertiesConstants.*; + +@Component +public class McpTriggerPropertiesBuilder implements ElementPropertiesBuilder { + @Override + public boolean applicableTo(ChainElement element) { + String type = element.getType(); + return MCP_TRIGGER_ELEMENT.equals(type); + } + + @Override + public Map build(ChainElement element) { + return Stream.of( + "mcpServiceIds", + "name", + "title", + "description", + "inputSchema", + "outputSchema", + "readOnly", + "destructive", + "idempotent", + "openWorld", + "requiresLocal" + ).collect(Collectors.toMap( + Function.identity(), + key -> Optional.ofNullable(element.getProperties().get(key)) + .map(String::valueOf) + .orElse(""))); + } +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/ContextExportImportService.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/ContextExportImportService.java index 3e494d0d..42b7ca38 100644 --- a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/ContextExportImportService.java +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/ContextExportImportService.java @@ -44,6 +44,7 @@ import org.qubership.integration.platform.runtime.catalog.service.ContextBaseService; import org.qubership.integration.platform.runtime.catalog.service.exportimport.deserializer.ContextServiceDeserializer; import org.qubership.integration.platform.runtime.catalog.service.exportimport.instructions.ImportInstructionsService; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.serializer.ArchiveWriter; import org.qubership.integration.platform.runtime.catalog.service.exportimport.serializer.ContextServiceSerializer; import org.qubership.integration.platform.runtime.catalog.util.ExportImportUtils; import org.springframework.beans.factory.annotation.Autowired; @@ -83,6 +84,7 @@ public class ContextExportImportService { protected final ActionsLogService actionLogger; private final ContextServiceSerializer contextServiceSerializer; private final ContextServiceDeserializer contextServiceDeserializer; + private final ArchiveWriter archiveWriter; private final ImportSessionService importProgressService; private final ImportInstructionsService importInstructionsService; @@ -95,15 +97,19 @@ public ContextExportImportService( YAMLMapper yamlExportImportMapper, ActionsLogService actionLogger, ContextServiceSerializer contextServiceSerializer, - ContextServiceDeserializer contextServiceDeserializer, ImportSessionService importProgressService, + ContextServiceDeserializer contextServiceDeserializer, + ArchiveWriter archiveWriter, + ImportSessionService importProgressService, ImportInstructionsService importInstructionsService, - @Value("${qip.json.schemas.context-service:http://qubership.org/schemas/product/qip/context-service}") URI contextServiceSchemaUri) { + @Value("${qip.json.schemas.context-service:http://qubership.org/schemas/product/qip/context-service}") URI contextServiceSchemaUri + ) { this.transactionTemplate = transactionTemplate; this.contextBaseService = contextBaseService; this.yamlMapper = yamlExportImportMapper; this.actionLogger = actionLogger; this.contextServiceSerializer = contextServiceSerializer; this.contextServiceDeserializer = contextServiceDeserializer; + this.archiveWriter = archiveWriter; this.importProgressService = importProgressService; this.importInstructionsService = importInstructionsService; this.contextServiceSchemaUri = contextServiceSchemaUri; @@ -145,7 +151,7 @@ public byte[] exportSystemsRequest(List systemIds) { } List exportedSystems = exportSystems(systems); - byte[] archive = contextServiceSerializer.writeSerializedArchive(exportedSystems); + byte[] archive = archiveWriter.writeArchive(exportedSystems); for (ContextSystem system : systems) { logSystemExportImport(system, null, LogOperation.EXPORT); } diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/ExportImportConstants.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/ExportImportConstants.java index 03919d29..40436b52 100644 --- a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/ExportImportConstants.java +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/ExportImportConstants.java @@ -38,6 +38,9 @@ public class ExportImportConstants { @Deprecated public static final String CONTEXT_SERVICE_YAML_NAME_PREFIX = "context-service-"; public static final String CONTEXT_SERVICE_YAML_NAME_POSTFIX = ".context-service."; + @Deprecated + public static final String MCP_SERVICE_YAML_NAME_PREFIX = "mcp-service-"; + public static final String MCP_SERVICE_YAML_NAME_POSTFIX = ".mcp-service."; public static final String SOURCE_YAML_NAME_PREFIX = "source-"; public static final String EXPORT_FILE_NAME_PREFIX = "export-"; @Deprecated diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/GeneralImportService.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/GeneralImportService.java index 9373f4a2..80ee344a 100644 --- a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/GeneralImportService.java +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/GeneralImportService.java @@ -58,6 +58,7 @@ public class GeneralImportService { private final CommonVariablesService commonVariablesService; private final SystemExportImportService systemExportImportService; private final ContextExportImportService contextExportImportService; + private final MCPSystemImportExportService mcpSystemImportExportService; private final ChainImportService chainImportService; private final ImportSessionService importSessionService; private final ActionsLogService actionsLogService; @@ -68,7 +69,9 @@ public class GeneralImportService { public GeneralImportService( CommonVariablesService commonVariablesService, SystemExportImportService systemExportImportService, - ContextExportImportService contextExportImportService, ChainImportService chainImportService, + ContextExportImportService contextExportImportService, + MCPSystemImportExportService mcpSystemImportExportService, + ChainImportService chainImportService, ImportSessionService importSessionService, ActionsLogService actionsLogService, ImportInstructionsService importInstructionsService, @@ -77,6 +80,7 @@ public GeneralImportService( this.commonVariablesService = commonVariablesService; this.systemExportImportService = systemExportImportService; this.contextExportImportService = contextExportImportService; + this.mcpSystemImportExportService = mcpSystemImportExportService; this.chainImportService = chainImportService; this.importSessionService = importSessionService; this.actionsLogService = actionsLogService; @@ -113,6 +117,7 @@ public ImportPreviewResponse getImportPreview(MultipartFile file) { .chains(chainImportService.getChainsImportPreview(unpackedDirectory, instructionsConfig.getChains())) .systems(systemExportImportService.getSystemsImportPreview(unpackedDirectory, instructionsConfig.getServices())) .contextService(contextExportImportService.getContextServiceImportPreview(unpackedDirectory, instructionsConfig.getContextServices())) + .mcpService(mcpSystemImportExportService.getImportPreview(unpackedDirectory, instructionsConfig.getMcpServices())) .instructions(generalInstructionsMapper.asDTO(importInstructions)) .build(); } finally { @@ -162,6 +167,7 @@ public String importFileAsync(MultipartFile file, ImportRequest importRequest, S ImportSystemsAndInstructionsResult importSystemsAndInstructionsResult = systemExportImportService .importSystems(unpackedDirectory, importRequest.getSystemsCommitRequest(), importId, technicalLabels); ImportContextServiceAndInstructionsResult importChainsAndContextInstructionsResult = contextExportImportService.importContextService(unpackedDirectory, importRequest.getSystemsCommitRequest(), importId); + ImportSystemsAndInstructionsResult importMcpSystemsAndInstructionsResult = mcpSystemImportExportService.importSystems(unpackedDirectory, importRequest.getSystemsCommitRequest(), importId); ImportChainsAndInstructionsResult importChainsAndInstructionsResult = chainImportService .importChains(unpackedDirectory, importRequest.getChainCommitRequests(), importId, technicalLabels, validateByHash); diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/MCPSystemImportExportService.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/MCPSystemImportExportService.java new file mode 100644 index 00000000..baabf5fb --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/MCPSystemImportExportService.java @@ -0,0 +1,453 @@ +package org.qubership.integration.platform.runtime.catalog.service.exportimport; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.FilenameUtils; +import org.qubership.integration.platform.runtime.catalog.exception.exceptions.ServicesNotFoundException; +import org.qubership.integration.platform.runtime.catalog.model.exportimport.chain.ImportSystemsAndInstructionsResult; +import org.qubership.integration.platform.runtime.catalog.model.exportimport.instructions.IgnoreResult; +import org.qubership.integration.platform.runtime.catalog.model.exportimport.instructions.ImportInstructionAction; +import org.qubership.integration.platform.runtime.catalog.model.exportimport.instructions.ImportInstructionsConfig; +import org.qubership.integration.platform.runtime.catalog.model.exportimport.system.ImportSystemResult; +import org.qubership.integration.platform.runtime.catalog.model.system.exportimport.ExportedSystemObject; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.actionlog.ActionLog; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.actionlog.EntityType; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.actionlog.LogOperation; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.mcp.MCPSystem; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.system.AbstractSystemEntity; +import org.qubership.integration.platform.runtime.catalog.rest.v1.dto.system.imports.ImportSystemStatus; +import org.qubership.integration.platform.runtime.catalog.rest.v1.dto.system.imports.remote.SystemCompareAction; +import org.qubership.integration.platform.runtime.catalog.rest.v3.dto.exportimport.ImportMode; +import org.qubership.integration.platform.runtime.catalog.rest.v3.dto.exportimport.system.SystemsCommitRequest; +import org.qubership.integration.platform.runtime.catalog.service.ActionsLogService; +import org.qubership.integration.platform.runtime.catalog.service.MCPSystemService; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.deserializer.MCPSystemDeserializer; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.instructions.ImportInstructionsService; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.serializer.ArchiveWriter; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.serializer.MCPSystemSerializer; +import org.qubership.integration.platform.runtime.catalog.util.ExportImportUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.util.CollectionUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.file.Paths; +import java.sql.Timestamp; +import java.util.*; +import java.util.stream.Collectors; + +import static java.util.Objects.isNull; +import static org.qubership.integration.platform.runtime.catalog.service.exportimport.ExportImportConstants.MCP_SERVICE_YAML_NAME_POSTFIX; +import static org.qubership.integration.platform.runtime.catalog.service.exportimport.ExportImportConstants.ZIP_EXTENSION; +import static org.qubership.integration.platform.runtime.catalog.util.ExportImportUtils.*; +import static org.springframework.transaction.annotation.Propagation.NOT_SUPPORTED; + +@Slf4j +@Service +@Transactional +public class MCPSystemImportExportService { + private final TransactionTemplate transactionTemplate; + private final YAMLMapper yamlMapper; + private final MCPSystemService mcpSystemService; + private final ActionsLogService actionLogger; + private final MCPSystemSerializer mcpSystemSerializer; + private final MCPSystemDeserializer mcpSystemDeserializer; + private final ArchiveWriter archiveWriter; + private final ImportInstructionsService importInstructionsService; + private final ImportSessionService importProgressService; + private final URI mcpServiceSchemaUri; + + @Autowired + public MCPSystemImportExportService( + TransactionTemplate transactionTemplate, + @Qualifier("yamlExportImportMapper") YAMLMapper yamlMapper, + MCPSystemService mcpSystemService, + ActionsLogService actionLogger, + MCPSystemSerializer mcpSystemSerializer, + MCPSystemDeserializer mcpSystemDeserializer, + ArchiveWriter archiveWriter, + ImportInstructionsService importInstructionsService, + ImportSessionService importProgressService, + @Value("${qip.json.schemas.mcp-service:http://qubership.org/schemas/product/qip/mcp-service}") URI mcpServiceSchemaUri + ) { + this.transactionTemplate = transactionTemplate; + this.yamlMapper = yamlMapper; + this.mcpSystemService = mcpSystemService; + this.actionLogger = actionLogger; + this.mcpSystemSerializer = mcpSystemSerializer; + this.mcpSystemDeserializer = mcpSystemDeserializer; + this.archiveWriter = archiveWriter; + this.importInstructionsService = importInstructionsService; + this.importProgressService = importProgressService; + this.mcpServiceSchemaUri = mcpServiceSchemaUri; + } + + public byte[] export(List ids) { + List systems = isNull(ids) + ? mcpSystemService.findAll() + : mcpSystemService.findAllById(ids); + if (systems.isEmpty()) { + return null; + } + + List exportedSystems = exportSystems(systems); + byte[] data = archiveWriter.writeArchive(exportedSystems); + systems.forEach(this::logExport); + return data; + } + + private List exportSystems(List systems) { + return systems.stream().map(this::exportSystem).toList(); + } + + private ExportedSystemObject exportSystem(MCPSystem system) { + try { + return mcpSystemSerializer.serialize(system); + } catch (JsonProcessingException e) { + String message = String.format("Failed to export system: %s (%s)", + system.getName(), system.getId()); + throw new RuntimeException(message, e); + } + } + + @Transactional(propagation = NOT_SUPPORTED) + public List importSystems( + MultipartFile file, + List ids + ) { + List response = new ArrayList<>(); + String fileExtension = FilenameUtils.getExtension(file.getOriginalFilename()); + logArchiveImport(file.getOriginalFilename()); + if (ZIP_EXTENSION.equalsIgnoreCase(fileExtension)) { + String exportDirectory = Paths.get(FileUtils.getTempDirectory().getAbsolutePath(), + UUID.randomUUID().toString()).toString(); + List extractedSystemFiles; + + try (InputStream fs = file.getInputStream()) { + extractedSystemFiles = extractSystemsFromZip(fs, exportDirectory, MCP_SERVICE_YAML_NAME_POSTFIX); + } catch (IOException e) { + deleteFile(exportDirectory); + throw new RuntimeException("Unexpected error while archive unpacking: " + e.getMessage(), e); + } catch (RuntimeException e) { + deleteFile(exportDirectory); + throw e; + } + + Set servicesToImport = importInstructionsService.performServiceIgnoreInstructions( + extractedSystemFiles.stream() + .map(ExportImportUtils::extractSystemIdFromFileName) + .collect(Collectors.toSet()), + false) + .idsToImport(); + for (File singleSystemFile : extractedSystemFiles) { + String serviceId = extractSystemIdFromFileName(singleSystemFile); + if (!servicesToImport.contains(serviceId)) { + addIgnoredServiceResult(response, serviceId); + continue; + } + + ImportSystemResult result = importOneSystemInTransaction(singleSystemFile, ids); + if (result != null) { + response.add(result); + } + } + + deleteFile(exportDirectory); + } else { + throw new RuntimeException("Unsupported file extension: " + fileExtension); + } + + return response; + } + + @Transactional(propagation = NOT_SUPPORTED) + public ImportSystemsAndInstructionsResult importSystems( + File importDirectory, + SystemsCommitRequest systemCommitRequest, + String importId + ) { + if (systemCommitRequest.getImportMode() == ImportMode.NONE) { + return new ImportSystemsAndInstructionsResult(); + } + + List systemsFiles = extractMcpServiceFilesFromDirectory(importDirectory); + + List systemIds = systemCommitRequest.getImportMode() == ImportMode.FULL + ? Collections.emptyList() + : systemCommitRequest.getSystemIds(); + + IgnoreResult ignoreResult = importInstructionsService.performServiceIgnoreInstructions( + systemsFiles.stream() + .map(ExportImportUtils::extractSystemIdFromFileName) + .collect(Collectors.toSet()), + true); + int total = systemsFiles.size(); + int counter = 0; + List response = new ArrayList<>(); + for (File systemFile : systemsFiles) { + String serviceId = extractSystemIdFromFileName(systemFile); + if (!ignoreResult.idsToImport().contains(serviceId)) { + addIgnoredServiceResult(response, serviceId); + continue; + } + + importProgressService.calculateImportStatus( + importId, total, counter, ImportSessionService.COMMON_VARIABLES_IMPORT_PERCENTAGE_THRESHOLD, + ImportSessionService.SERVICE_IMPORT_PERCENTAGE_THRESHOLD); + counter++; + + ImportSystemResult result = importOneSystemInTransaction(systemFile, systemIds); + + if (result != null) { + response.add(result); + } + } + + return new ImportSystemsAndInstructionsResult(response, ignoreResult.importInstructionResults()); + } + + public List getImportPreview(MultipartFile file) { + List result = new ArrayList<>(); + String fileExtension = FilenameUtils.getExtension(file.getOriginalFilename()); + if (ZIP_EXTENSION.equalsIgnoreCase(fileExtension)) { + String dataDirectory = Paths.get(FileUtils.getTempDirectory().getAbsolutePath(), + UUID.randomUUID().toString()).toString(); + List extractedSystemFiles = new ArrayList<>(); + + try (InputStream fs = file.getInputStream()) { + extractedSystemFiles = extractSystemsFromZip(fs, dataDirectory, MCP_SERVICE_YAML_NAME_POSTFIX); + } catch (ServicesNotFoundException e) { + deleteFile(dataDirectory); + } catch (IOException e) { + deleteFile(dataDirectory); + throw new RuntimeException("Unexpected error while archive unpacking: " + e.getMessage(), e); + } catch (RuntimeException e) { + deleteFile(dataDirectory); + throw e; + } + + ImportInstructionsConfig instructionsConfig = importInstructionsService + .getServiceImportInstructionsConfig(Set.of(ImportInstructionAction.IGNORE)); + for (File singleSystemFile : extractedSystemFiles) { + result.add(getSystemChanges(singleSystemFile, instructionsConfig)); + } + deleteFile(dataDirectory); + } else { + throw new RuntimeException("Unsupported file extension: " + fileExtension); + } + + return result; + } + + public List getImportPreview( + File importDirectory, + ImportInstructionsConfig instructionsConfig + ) { + List contextServiceFiles = extractMcpServiceFilesFromDirectory(importDirectory); + + List importSystemResults = new ArrayList<>(); + for (File systemFile : contextServiceFiles) { + importSystemResults.add(getSystemChanges(systemFile, instructionsConfig)); + } + + return importSystemResults; + } + + private void addIgnoredServiceResult(List response, String serviceId) { + response.add(ImportSystemResult.builder() + .id(serviceId) + .name(serviceId) + .status(ImportSystemStatus.IGNORED) + .build()); + log.info("Service {} ignored as a part of import exclusion list", serviceId); + } + + private List extractMcpServiceFilesFromDirectory(File importDirectory) { + List systemsFiles; + try { + systemsFiles = extractSystemsFromImportDirectory(importDirectory.getAbsolutePath(), + MCP_SERVICE_YAML_NAME_POSTFIX); + } catch (IOException e) { + throw new RuntimeException("Error while extracting MCP service files", e); + } + return systemsFiles.stream() + .filter(this::isMCPServiceFile) + .collect(Collectors.toList()); + } + + private ObjectNode getFileNode(File file) throws IOException { + return (ObjectNode) yamlMapper.readTree(file); + } + + private boolean isMCPServiceFile(File file) { + try { + ObjectNode node = getFileNode(file); + JsonNode schemaNode = node.get("$schema"); + if (schemaNode != null && schemaNode.isTextual()) { + String fileSchema = schemaNode.asText(); + return mcpServiceSchemaUri.toString().equals(fileSchema); + } + return false; + } catch (Exception e) { + log.warn("Failed to check schema for file {}: {}", file.getName(), e.getMessage()); + return false; + } + } + + private ImportSystemResult getSystemChanges(File mainSystemFile, ImportInstructionsConfig instructionsConfig) { + ImportSystemResult resultSystemCompareDTO; + + String systemId = null; + String systemName = null; + + try { + ObjectNode serviceNode = getFileNode(mainSystemFile); + MCPSystem baseSystem = getBaseSystemDeserializationResult(serviceNode); + systemId = baseSystem.getId(); + systemName = baseSystem.getName(); + Long systemModifiedWhen = baseSystem.getModifiedWhen() != null ? baseSystem.getModifiedWhen().getTime() : 0; + ImportInstructionAction instructionAction = instructionsConfig.getIgnore().contains(systemId) + ? ImportInstructionAction.IGNORE + : null; + + resultSystemCompareDTO = ImportSystemResult.builder() + .id(systemId) + .modified(systemModifiedWhen) + .instructionAction(instructionAction) + .build(); + setCompareSystemResult(baseSystem, resultSystemCompareDTO); + } catch (RuntimeException | IOException e) { + log.error("Exception while system compare: ", e); + resultSystemCompareDTO = ImportSystemResult.builder() + .id(systemId) + .name(systemName) + .requiredAction(SystemCompareAction.ERROR) + .message("Exception while system compare: " + e.getMessage()) + .build(); + } + return resultSystemCompareDTO; + } + + private MCPSystem getBaseSystemDeserializationResult(JsonNode serviceNode) + throws JsonProcessingException { + MCPSystem result = new MCPSystem(); + + JsonNode idNode = serviceNode.get(AbstractSystemEntity.Fields.id); + String systemId = (idNode == null || idNode.isNull()) ? null : idNode.asText(); + if (systemId == null) { + throw new RuntimeException("Missing id field in system file"); + } + + JsonNode nameNode = serviceNode.get(AbstractSystemEntity.Fields.name); + String systemName = (nameNode == null || nameNode.isNull()) ? "" : nameNode.asText(); + + Timestamp modifiedWhen = serviceNode.get(AbstractSystemEntity.Fields.modifiedWhen) != null + ? new Timestamp(serviceNode.get(AbstractSystemEntity.Fields.modifiedWhen).asLong()) + : null; + + result.setId(systemId); + result.setName(systemName); + result.setModifiedWhen(modifiedWhen); + + return result; + } + + private void setCompareSystemResult(MCPSystem system, ImportSystemResult result) { + mcpSystemService.findById(system.getId()).ifPresentOrElse( + oldSystem -> { + result.setName(oldSystem.getName()); + result.setRequiredAction(SystemCompareAction.UPDATE); + }, + () -> { + result.setName(system.getName()); + result.setRequiredAction(SystemCompareAction.CREATE); + } + ); + } + + private synchronized ImportSystemResult importOneSystemInTransaction( + File mainServiceFile, + List systemIds + ) { + ImportSystemResult result; + Optional baseSystemOptional = Optional.empty(); + + try { + ObjectNode serviceNode = getFileNode(mainServiceFile); + MCPSystem system = getBaseSystemDeserializationResult(serviceNode); + baseSystemOptional = Optional.of(system); + if (!CollectionUtils.isEmpty(systemIds) && !systemIds.contains(system.getId())) { + return null; + } + result = transactionTemplate.execute((status) -> { + MCPSystem mcpSystem = system; + try { + mcpSystem = mcpSystemDeserializer.deserialize(mainServiceFile); + } catch (Exception e) { + throw new RuntimeException(e); + } + + ImportSystemStatus importStatus = enrichAndSaveSystem(mcpSystem); + return ImportSystemResult.builder() + .id(mcpSystem.getId()) + .name(mcpSystem.getName()) + .status(importStatus) + .build(); + }); + } catch (Exception e) { + result = ImportSystemResult.builder() + .id(baseSystemOptional.map(MCPSystem::getId).orElse(null)) + .name(baseSystemOptional.map(MCPSystem::getName).orElse("")) + .status(ImportSystemStatus.ERROR) + .message(e.getMessage()) + .build(); + log.warn("Exception when importing MCP system {} ({})", result.getName(), result.getId(), e); + } + return result; + } + + private ImportSystemStatus enrichAndSaveSystem(MCPSystem system) { + Optional oldSystem = mcpSystemService.findById(system.getId()); + + if (oldSystem.isPresent()) { + mcpSystemService.updateLabels(system, oldSystem.get().getLabels(), system.getLabels()); + system.setLabels(oldSystem.get().getLabels()); + mcpSystemService.update(system); + return ImportSystemStatus.UPDATED; + } else { + mcpSystemService.create(system, true); + return ImportSystemStatus.CREATED; + } + } + + private void logExport(MCPSystem system) { + actionLogger.logAction(ActionLog.builder() + .entityType(EntityType.MCP_SYSTEM) + .entityId(system.getId()) + .entityName(system.getName()) + .operation(LogOperation.EXPORT) + .build() + ); + } + + private void logArchiveImport(String archiveName) { + actionLogger.logAction(ActionLog.builder() + .entityType(EntityType.SERVICES) + .entityId(null) + .entityName(archiveName) + .operation(LogOperation.IMPORT) + .build()); + } +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/SystemExportImportService.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/SystemExportImportService.java index c485a51c..57699e2e 100644 --- a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/SystemExportImportService.java +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/SystemExportImportService.java @@ -47,6 +47,7 @@ import org.qubership.integration.platform.runtime.catalog.service.*; import org.qubership.integration.platform.runtime.catalog.service.exportimport.deserializer.ServiceDeserializer; import org.qubership.integration.platform.runtime.catalog.service.exportimport.instructions.ImportInstructionsService; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.serializer.ArchiveWriter; import org.qubership.integration.platform.runtime.catalog.service.exportimport.serializer.ServiceSerializer; import org.qubership.integration.platform.runtime.catalog.service.helpers.ElementHelperService; import org.qubership.integration.platform.runtime.catalog.util.ExportImportUtils; @@ -98,6 +99,7 @@ public class SystemExportImportService { private final AuditingHandler auditingHandler; private final ServiceSerializer serviceSerializer; private final ServiceDeserializer serviceDeserializer; + private final ArchiveWriter archiveWriter; private final ImportSessionService importProgressService; private final ImportInstructionsService importInstructionsService; private final ElementHelperService elementHelperService; @@ -118,6 +120,7 @@ public SystemExportImportService( AuditingHandler jpaAuditingHandler, ServiceSerializer serviceSerializer, ServiceDeserializer serviceDeserializer, + ArchiveWriter archiveWriter, ImportSessionService importProgressService, ImportInstructionsService importInstructionsService, ElementHelperService elementHelperService, @@ -133,6 +136,7 @@ public SystemExportImportService( this.auditingHandler = jpaAuditingHandler; this.serviceSerializer = serviceSerializer; this.serviceDeserializer = serviceDeserializer; + this.archiveWriter = archiveWriter; this.importProgressService = importProgressService; this.importInstructionsService = importInstructionsService; this.elementHelperService = elementHelperService; @@ -200,7 +204,7 @@ public byte[] exportSystemsRequest(List systemIds, List usedSyst } List exportedSystems = exportSystems(systems, usedSystemModelIds); - byte[] archive = serviceSerializer.writeSerializedArchive(exportedSystems); + byte[] archive = archiveWriter.writeArchive(exportedSystems); for (IntegrationSystem system : systems) { logSystemExportImport(system, null, LogOperation.EXPORT); } diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/deserializer/MCPSystemDeserializer.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/deserializer/MCPSystemDeserializer.java new file mode 100644 index 00000000..fe16f1af --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/deserializer/MCPSystemDeserializer.java @@ -0,0 +1,53 @@ +package org.qubership.integration.platform.runtime.catalog.service.exportimport.deserializer; + +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import org.qubership.integration.platform.runtime.catalog.exception.exceptions.ServiceImportException; +import org.qubership.integration.platform.runtime.catalog.model.exportimport.system.MCPServiceDto; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.mcp.MCPSystem; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.mapper.services.MCPServiceDtoMapper; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.migrations.FileMigrationService; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.migrations.ImportFileMigration; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.migrations.mcp.MCPServiceImportFileMigration; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.nio.file.Files; +import java.util.Collection; + +@Component +public class MCPSystemDeserializer { + private final YAMLMapper yamlMapper; + private final FileMigrationService fileMigrationService; + private final Collection importFileMigrations; + private final MCPServiceDtoMapper mcpServiceDtoMapper; + + @Autowired + public MCPSystemDeserializer( + @Qualifier("yamlExportImportMapper") YAMLMapper yamlMapper, + FileMigrationService fileMigrationService, + Collection importFileMigrations, + MCPServiceDtoMapper mcpServiceDtoMapper + ) { + this.yamlMapper = yamlMapper; + this.fileMigrationService = fileMigrationService; + this.importFileMigrations = importFileMigrations; + this.mcpServiceDtoMapper = mcpServiceDtoMapper; + } + + public MCPSystem deserialize(File serviceFile) { + try { + String serviceData = fileMigrationService.migrate( + Files.readString(serviceFile.toPath()), + importFileMigrations.stream().map(ImportFileMigration.class::cast).toList() + ); + MCPServiceDto mcpServiceDto = yamlMapper.readValue(serviceData, MCPServiceDto.class); + return mcpServiceDtoMapper.toInternalEntity(mcpServiceDto); + } catch (ServiceImportException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/mapper/services/MCPServiceDtoMapper.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/mapper/services/MCPServiceDtoMapper.java new file mode 100644 index 00000000..163126a9 --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/mapper/services/MCPServiceDtoMapper.java @@ -0,0 +1,74 @@ +package org.qubership.integration.platform.runtime.catalog.service.exportimport.mapper.services; + +import org.qubership.integration.platform.runtime.catalog.model.exportimport.system.MCPServiceContentDto; +import org.qubership.integration.platform.runtime.catalog.model.exportimport.system.MCPServiceDto; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.mcp.MCPSystem; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.mcp.MCPSystemLabel; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.mapper.ExternalEntityMapper; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.migrations.ImportFileMigration; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.migrations.mcp.MCPServiceImportFileMigration; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.util.List; +import java.util.stream.Collectors; + +@Component +public class MCPServiceDtoMapper implements ExternalEntityMapper { + private final URI schemaUri; + private final List migrations; + + @Autowired + public MCPServiceDtoMapper( + @Value("${qip.json.schemas.mcp-service:http://qubership.org/schemas/product/qip/mcp-service}") URI schemaUri, + List migrations + ) { + this.schemaUri = schemaUri; + this.migrations = migrations; + } + + @Override + public MCPSystem toInternalEntity(MCPServiceDto mcpServiceDto) { + MCPSystem system = MCPSystem.builder() + .id(mcpServiceDto.getId()) + .name(mcpServiceDto.getName()) + .description(mcpServiceDto.getContent().getDescription()) + .identifier(mcpServiceDto.getContent().getIdentifier()) + .instructions(mcpServiceDto.getContent().getInstructions()) + .createdBy(mcpServiceDto.getContent().getCreatedBy()) + .createdWhen(mcpServiceDto.getContent().getCreatedWhen()) + .modifiedBy(mcpServiceDto.getContent().getModifiedBy()) + .modifiedWhen(mcpServiceDto.getContent().getModifiedWhen()) + .build(); + system.setLabels(mcpServiceDto + .getContent() + .getLabels() + .stream() + .map(name -> new MCPSystemLabel(name, system)) + .collect(Collectors.toSet())); + return system; + } + + @Override + public MCPServiceDto toExternalEntity(MCPSystem system) { + return MCPServiceDto.builder() + .id(system.getId()) + .name(system.getName()) + .schema(schemaUri) + .content(MCPServiceContentDto.builder() + .description(system.getDescription()) + .identifier(system.getIdentifier()) + .instructions(system.getInstructions()) + .labels(system.getLabels().stream().map(MCPSystemLabel::getName).toList()) + .migrations(migrations + .stream() + .map(ImportFileMigration::getVersion) + .sorted() + .toList() + .toString()) + .build()) + .build(); + } +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/migrations/mcp/MCPServiceImportFileMigration.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/migrations/mcp/MCPServiceImportFileMigration.java new file mode 100644 index 00000000..5aa9a135 --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/migrations/mcp/MCPServiceImportFileMigration.java @@ -0,0 +1,6 @@ +package org.qubership.integration.platform.runtime.catalog.service.exportimport.migrations.mcp; + +import org.qubership.integration.platform.runtime.catalog.service.exportimport.migrations.ImportFileMigration; + +public interface MCPServiceImportFileMigration extends ImportFileMigration { +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/migrations/mcp/V100MCPServiceImportFileMigration.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/migrations/mcp/V100MCPServiceImportFileMigration.java new file mode 100644 index 00000000..a95cb57f --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/migrations/mcp/V100MCPServiceImportFileMigration.java @@ -0,0 +1,21 @@ +package org.qubership.integration.platform.runtime.catalog.service.exportimport.migrations.mcp; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class V100MCPServiceImportFileMigration implements MCPServiceImportFileMigration { + @Override + public int getVersion() { + return 100; + } + + @Override + public ObjectNode makeMigration(ObjectNode fileNode) throws JsonProcessingException { + log.debug("Initial MCP service migration V100"); + return fileNode; + } +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/serializer/ArchiveWriter.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/serializer/ArchiveWriter.java new file mode 100644 index 00000000..681043c2 --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/serializer/ArchiveWriter.java @@ -0,0 +1,37 @@ +package org.qubership.integration.platform.runtime.catalog.service.exportimport.serializer; + +import org.qubership.integration.platform.runtime.catalog.model.system.exportimport.ExportableObject; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.zip.ZipOutputStream; + +import static org.qubership.integration.platform.runtime.catalog.service.exportimport.ExportImportConstants.ARCH_PARENT_DIR; + +@Component +public class ArchiveWriter { + private final ExportableObjectWriterVisitor exportableObjectWriterVisitor; + + @Autowired + public ArchiveWriter(ExportableObjectWriterVisitor exportableObjectWriterVisitor) { + this.exportableObjectWriterVisitor = exportableObjectWriterVisitor; + } + + public byte[] writeArchive(List exportedSystems) { + try (ByteArrayOutputStream fos = new ByteArrayOutputStream()) { + try (ZipOutputStream zipOut = new ZipOutputStream(fos)) { + for (ExportableObject exportedSystem : exportedSystems) { + String entryPath = ARCH_PARENT_DIR + File.separator + exportedSystem.getId() + File.separator; + exportedSystem.accept(exportableObjectWriterVisitor, zipOut, entryPath); + } + } + return fos.toByteArray(); + } catch (IOException e) { + throw new RuntimeException("Failed to create archive: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/serializer/ContextServiceSerializer.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/serializer/ContextServiceSerializer.java index 45492f8c..7e7b0590 100644 --- a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/serializer/ContextServiceSerializer.java +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/serializer/ContextServiceSerializer.java @@ -27,29 +27,20 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; -import java.util.List; -import java.util.zip.ZipOutputStream; - -import static org.qubership.integration.platform.runtime.catalog.service.exportimport.ExportImportConstants.ARCH_PARENT_DIR; - @Component public class ContextServiceSerializer { private final YAMLMapper yamlMapper; - private final ExportableObjectWriterVisitor exportableObjectWriterVisitor; private final ContextServiceDtoMapper contextServiceDtoMapper; private final FileMigrationService fileMigrationService; @Autowired - public ContextServiceSerializer(YAMLMapper yamlExportImportMapper, - ExportableObjectWriterVisitor exportableObjectWriterVisitor, - ContextServiceDtoMapper contextServiceDtoMapper, - FileMigrationService fileMigrationService) { + public ContextServiceSerializer( + YAMLMapper yamlExportImportMapper, + ContextServiceDtoMapper contextServiceDtoMapper, + FileMigrationService fileMigrationService + ) { this.yamlMapper = yamlExportImportMapper; - this.exportableObjectWriterVisitor = exportableObjectWriterVisitor; this.contextServiceDtoMapper = contextServiceDtoMapper; this.fileMigrationService = fileMigrationService; } @@ -60,20 +51,4 @@ public ExportedSystemObject serialize(ContextSystem system) throws JsonProcessin return new ExportedContextService(system.getId(), systemNode); } - - - public byte[] writeSerializedArchive(List exportedSystems) { - try (ByteArrayOutputStream fos = new ByteArrayOutputStream()) { - try (ZipOutputStream zipOut = new ZipOutputStream(fos)) { - for (ExportedSystemObject exportedSystem : exportedSystems) { - String entryPath = ARCH_PARENT_DIR + File.separator + exportedSystem.getId() + File.separator; - exportedSystem.accept(exportableObjectWriterVisitor, zipOut, entryPath); - } - } - return fos.toByteArray(); - } catch (IOException e) { - throw new RuntimeException("Unknown exception while archive creation: " + e.getMessage(), e); - } - } - } diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/serializer/ExportableObjectWriterVisitor.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/serializer/ExportableObjectWriterVisitor.java index 77295643..f16652d1 100644 --- a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/serializer/ExportableObjectWriterVisitor.java +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/serializer/ExportableObjectWriterVisitor.java @@ -84,4 +84,10 @@ public void visit(ExportedContextService exportedContextService, ZipOutputStream entryPath + ExportImportUtils.generateMainContextServiceFileExportName(exportedContextService.getId(), appName, isLegacyExport), yamlMapper.writeValueAsString(exportedContextService.getObjectNode())); } + + public void visit(ExportedMCPSystemObject exportedMCPSystemObject, ZipOutputStream zipOut, String entryPath) throws IOException { + ExportImportUtils.writeSystemObject(zipOut, + entryPath + ExportImportUtils.generateMCPServiceFileExportName(exportedMCPSystemObject.getId(), appName, isLegacyExport), + yamlMapper.writeValueAsString(exportedMCPSystemObject.getObjectNode())); + } } diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/serializer/MCPSystemSerializer.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/serializer/MCPSystemSerializer.java new file mode 100644 index 00000000..3c9d6f00 --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/serializer/MCPSystemSerializer.java @@ -0,0 +1,38 @@ +package org.qubership.integration.platform.runtime.catalog.service.exportimport.serializer; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import org.qubership.integration.platform.runtime.catalog.model.exportimport.system.MCPServiceDto; +import org.qubership.integration.platform.runtime.catalog.model.system.exportimport.ExportedMCPSystemObject; +import org.qubership.integration.platform.runtime.catalog.model.system.exportimport.ExportedSystemObject; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.mcp.MCPSystem; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.mapper.services.MCPServiceDtoMapper; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.migrations.FileMigrationService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +@Component +public class MCPSystemSerializer { + private final YAMLMapper yamlMapper; + private final MCPServiceDtoMapper mcpServiceDtoMapper; + private final FileMigrationService fileMigrationService; + + @Autowired + public MCPSystemSerializer( + @Qualifier("yamlExportImportMapper") YAMLMapper yamlExportImportMapper, + MCPServiceDtoMapper mcpServiceDtoMapper, + FileMigrationService fileMigrationService + ) { + this.yamlMapper = yamlExportImportMapper; + this.mcpServiceDtoMapper = mcpServiceDtoMapper; + this.fileMigrationService = fileMigrationService; + } + + public ExportedSystemObject serialize(MCPSystem system) throws JsonProcessingException { + MCPServiceDto mcpServiceDto = mcpServiceDtoMapper.toExternalEntity(system); + ObjectNode systemNode = fileMigrationService.revertMigrationIfNeeded(yamlMapper.valueToTree(mcpServiceDto)); + return new ExportedMCPSystemObject(system.getId(), systemNode); + } +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/serializer/ServiceSerializer.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/serializer/ServiceSerializer.java index ef7a6527..88892095 100644 --- a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/serializer/ServiceSerializer.java +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/serializer/ServiceSerializer.java @@ -31,19 +31,12 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; import java.util.List; -import java.util.zip.ZipOutputStream; - -import static org.qubership.integration.platform.runtime.catalog.service.exportimport.ExportImportConstants.ARCH_PARENT_DIR; @Component public class ServiceSerializer { private final YAMLMapper yamlMapper; - private final ExportableObjectWriterVisitor exportableObjectWriterVisitor; private final IntegrationSystemDtoMapper integrationSystemDtoMapper; private final SpecificationGroupDtoMapper specificationGroupDtoMapper; private final SystemModelDtoMapper systemModelDtoMapper; @@ -52,14 +45,12 @@ public class ServiceSerializer { @Autowired public ServiceSerializer( YAMLMapper yamlExportImportMapper, - ExportableObjectWriterVisitor exportableObjectWriterVisitor, IntegrationSystemDtoMapper integrationSystemDtoMapper, SpecificationGroupDtoMapper specificationGroupDtoMapper, SystemModelDtoMapper systemModelDtoMapper, FileMigrationService fileMigrationService ) { this.yamlMapper = yamlExportImportMapper; - this.exportableObjectWriterVisitor = exportableObjectWriterVisitor; this.integrationSystemDtoMapper = integrationSystemDtoMapper; this.specificationGroupDtoMapper = specificationGroupDtoMapper; this.systemModelDtoMapper = systemModelDtoMapper; @@ -103,18 +94,4 @@ public ExportedSpecification serialize(SystemModel specification) { return new ExportedSpecification(specification.getId(), node, exportedSpecificationSources); } - - public byte[] writeSerializedArchive(List exportedSystems) { - try (ByteArrayOutputStream fos = new ByteArrayOutputStream()) { - try (ZipOutputStream zipOut = new ZipOutputStream(fos)) { - for (ExportedSystemObject exportedSystem : exportedSystems) { - String entryPath = ARCH_PARENT_DIR + File.separator + exportedSystem.getId() + File.separator; - exportedSystem.accept(exportableObjectWriterVisitor, zipOut, entryPath); - } - } - return fos.toByteArray(); - } catch (IOException e) { - throw new RuntimeException("Unknown exception while archive creation: " + e.getMessage(), e); - } - } } diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/service/filter/MCPSystemFilterSpecificationBuilder.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/filter/MCPSystemFilterSpecificationBuilder.java new file mode 100644 index 00000000..f8be154e --- /dev/null +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/service/filter/MCPSystemFilterSpecificationBuilder.java @@ -0,0 +1,122 @@ +package org.qubership.integration.platform.runtime.catalog.service.filter; + +import jakarta.persistence.criteria.*; +import org.apache.poi.util.StringUtil; +import org.qubership.integration.platform.runtime.catalog.model.filter.FilterCondition; +import org.qubership.integration.platform.runtime.catalog.model.filter.FilterFeature; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.mcp.MCPSystem; +import org.qubership.integration.platform.runtime.catalog.rest.v1.dto.FilterRequestDTO; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.function.BiFunction; +import java.util.stream.Stream; + +import static java.util.Objects.isNull; + +@Component +public class MCPSystemFilterSpecificationBuilder { + private final FilterConditionPredicateBuilderFactory filterConditionPredicateBuilderFactory; + + @Autowired + public MCPSystemFilterSpecificationBuilder( + FilterConditionPredicateBuilderFactory filterConditionPredicateBuilderFactory + ) { + this.filterConditionPredicateBuilderFactory = filterConditionPredicateBuilderFactory; + } + + public Specification buildSearchAndFilters(String searchString, Collection filters) { + return (root, query, criteriaBuilder) -> { + Predicate searchPredicate = StringUtil.isBlank(searchString) + ? criteriaBuilder.conjunction() + : buildSearch(searchString, root, criteriaBuilder); + Predicate filterPredicate = isNull(filters) || filters.isEmpty() + ? criteriaBuilder.conjunction() + : buildFilters(filters, root, criteriaBuilder); + return criteriaBuilder.and(searchPredicate, filterPredicate); + }; + } + + private Predicate buildSearch( + String searchString, + Root root, + CriteriaBuilder criteriaBuilder + ) { + Collection filters = buildFiltersFromSearchString(searchString); + return build(filters, CriteriaBuilder::or, root, criteriaBuilder); + } + + private Predicate buildFilters( + Collection filters, + Root root, + CriteriaBuilder criteriaBuilder + ) { + return build(filters, CriteriaBuilder::and, root, criteriaBuilder); + } + + private List buildFiltersFromSearchString(String searchString) { + return Stream.of( + FilterFeature.ID, + FilterFeature.NAME, + FilterFeature.DESCRIPTION, + FilterFeature.IDENTIFIER, + FilterFeature.INSTRUCTIONS, + FilterFeature.LABELS + ).map(feature -> FilterRequestDTO + .builder() + .feature(feature) + .value(searchString) + .condition(FilterCondition.CONTAINS) + .build() + ).toList(); + } + + public Predicate build( + Collection filters, + BiFunction predicateAccumulator, + Root root, + CriteriaBuilder criteriaBuilder + ) { + Predicate[] predicates = filters.stream() + .map(filter -> buildPredicate(root, criteriaBuilder, filter)) + .toArray(Predicate[]::new); + return predicateAccumulator.apply(criteriaBuilder, predicates); + } + + private Predicate buildPredicate( + Root root, + CriteriaBuilder criteriaBuilder, + FilterRequestDTO filter + ) { + var conditionPredicateBuilder = filterConditionPredicateBuilderFactory + .getPredicateBuilder(criteriaBuilder, filter.getCondition()); + String value = filter.getValue(); + return switch (filter.getFeature()) { + case ID -> conditionPredicateBuilder.apply(root.get("id"), value); + case NAME -> conditionPredicateBuilder.apply(root.get("name"), value); + case DESCRIPTION -> conditionPredicateBuilder.apply(root.get("description"), value); + case INSTRUCTIONS -> conditionPredicateBuilder.apply(root.get("instructions"), value); + case IDENTIFIER -> conditionPredicateBuilder.apply(root.get("identifier"), value); + case LABELS -> { + Predicate predicate = conditionPredicateBuilder.apply(getJoin(root, "labels").get("name"), value); + boolean negativeLabelFilter = + filter.getCondition() == FilterCondition.IS_NOT + || filter.getCondition() == FilterCondition.DOES_NOT_CONTAIN; + + yield negativeLabelFilter + ? criteriaBuilder.or(predicate, criteriaBuilder.isNull(getJoin(root, "labels").get("name"))) + : predicate; + } + default -> throw new IllegalStateException("Unexpected filter feature: " + filter.getFeature()); + }; + } + + private Join getJoin(Root root, String attributeName) { + return root.getJoins().stream() + .filter(join -> join.getAttribute().getName().equals(attributeName)) + .findAny() + .orElseGet(() -> root.join(attributeName, JoinType.LEFT)); + } +} diff --git a/src/main/java/org/qubership/integration/platform/runtime/catalog/util/ExportImportUtils.java b/src/main/java/org/qubership/integration/platform/runtime/catalog/util/ExportImportUtils.java index 197bcfc4..7fbe2a01 100644 --- a/src/main/java/org/qubership/integration/platform/runtime/catalog/util/ExportImportUtils.java +++ b/src/main/java/org/qubership/integration/platform/runtime/catalog/util/ExportImportUtils.java @@ -229,6 +229,12 @@ public static String generateMainContextServiceFileExportName(String id, String : id + CONTEXT_SERVICE_YAML_NAME_POSTFIX + appName + YAML_FILE_NAME_POSTFIX; } + public static String generateMCPServiceFileExportName(String id, String appName, boolean isLegacyExport) { + return isLegacyExport + ? MCP_SERVICE_YAML_NAME_PREFIX + id + "." + YAML_EXTENSION + : id + MCP_SERVICE_YAML_NAME_POSTFIX + appName + YAML_FILE_NAME_POSTFIX; + } + public static String generateSourceExportDir(String id) { return SOURCE_YAML_NAME_PREFIX + id; } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 33f39cb5..70b1ffc8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -197,6 +197,7 @@ qip: chain: ${CHAIN_JSON_SCHEMA_URI:http://qubership.org/schemas/product/qip/chain} service: ${SERVICE_JSON_SCHEMA_URI:http://qubership.org/schemas/product/qip/service} context-service: ${CONTEXT_SERVICE_JSON_SCHEMA_URI:http://qubership.org/schemas/product/qip/context-service} + mcp-service: ${MCP_SERVICE_JSON_SCHEMA_URI:http://qubership.org/schemas/product/qip/mcp-service} specification-group: ${SPECIFICATION_GROUP_JSON_SCHEMA_URI:http://qubership.org/schemas/product/qip/specification-group} specification: ${SPECIFICATION_JSON_SCHEMA_URI:http://qubership.org/schemas/product/qip/specification} element-descriptors: diff --git a/src/main/resources/db/migration/postgresql/configs/V110_000__add-mcp-systems.sql b/src/main/resources/db/migration/postgresql/configs/V110_000__add-mcp-systems.sql new file mode 100644 index 00000000..2b5cbbbb --- /dev/null +++ b/src/main/resources/db/migration/postgresql/configs/V110_000__add-mcp-systems.sql @@ -0,0 +1,31 @@ +create table mcp_systems +( + id varchar(255) not null + constraint pk_mcp_system primary key, + name text, + description text, + created_when timestamptz, + modified_when timestamptz, + created_by_id text, + created_by_name text, + modified_by_id text, + modified_by_name text, + + identifier text unique, + instructions text +); + +create table mcp_system_labels +( + id varchar(255) not null, + name text not null, + mcp_system_id varchar(255) not null + references mcp_systems on delete cascade, + technical boolean default false +); + +create index idx_mcp_system_labels_mcp_system_id + ON mcp_system_labels (mcp_system_id); + +create unique index uk_mcp_system_labels + ON mcp_system_labels (name, mcp_system_id, technical); diff --git a/src/main/resources/elements/mcp-trigger/description.yml b/src/main/resources/elements/mcp-trigger/description.yml new file mode 100644 index 00000000..05d8e579 --- /dev/null +++ b/src/main/resources/elements/mcp-trigger/description.yml @@ -0,0 +1,78 @@ +# Copyright 2024-2025 NetCracker Technology Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: mcp-trigger +title: MCP Trigger +description: Expose integration chain as a MCP tool or resource. +type: trigger +folder: triggers +inputEnabled: false +colorType: trigger +outputEnabled: true +container: false +customTabs: + - name: Idempotency + uiComponent: idempotency-parameters +properties: + common: + - name: name + title: Identifier + type: string + mandatory: true + - name: title + title: Title + description: Human-readable name of the tool for display purposes + type: string + mandatory: false + - name: description + title: Description + description: Human-readable description of functionality + type: string + mandatory: true + - name: inputSchema + title: Input schema + description: JSON Schema defining expected parameters + type: string + mandatory: true + default: '{ "type": "object", "additionalProperties": false }' + - name: outputSchema + title: Output schema + description: Optional JSON Schema defining expected output structure + type: string + mandatory: false + - name: readOnly + title: Tool doesn't modify its environment + description: Indicates the tool only performs read operations and does not change any external state. + type: boolean + default: false + - name: destructive + type: boolean + default: false + title: Tool may perform destructive updates (delete/overwrite data) + description: Signals that the tool will modify or delete data, often used to trigger a "human-in-the-loop" (HITL) approval step. + - name: idempotent + type: boolean + default: false + title: Calling repeatedly with same args has no additional effect + description: Confirms that running the tool multiple times with the same inputs will produce the same result without unintended side effects. + - name: openWorld + type: boolean + default: false + title: Tool may interact with external entities beyond its local environment + description: Defines if the tool interacts with an unpredictable external environment (like a web search) or a closed, well-defined system. + - name: requiresLocal + type: boolean + default: false + title: Tool needs local resources or execution + description: A specialized hint (common in Azure MCP) indicating the tool needs local resources or execution. diff --git a/src/main/resources/elements/mcp-trigger/template.hbs b/src/main/resources/elements/mcp-trigger/template.hbs new file mode 100644 index 00000000..80481dc2 --- /dev/null +++ b/src/main/resources/elements/mcp-trigger/template.hbs @@ -0,0 +1,5 @@ + + + + {{> idempotency}} + diff --git a/src/test/java/org/qubership/integration/platform/runtime/catalog/rest/v1/controller/MCPSystemControllerTest.java b/src/test/java/org/qubership/integration/platform/runtime/catalog/rest/v1/controller/MCPSystemControllerTest.java new file mode 100644 index 00000000..020fc27b --- /dev/null +++ b/src/test/java/org/qubership/integration/platform/runtime/catalog/rest/v1/controller/MCPSystemControllerTest.java @@ -0,0 +1,327 @@ +/* + * Copyright 2024-2025 NetCracker Technology Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.qubership.integration.platform.runtime.catalog.rest.v1.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.qubership.integration.platform.runtime.catalog.model.exportimport.system.ImportSystemResult; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.mcp.MCPSystem; +import org.qubership.integration.platform.runtime.catalog.rest.v1.dto.system.imports.ImportSystemStatus; +import org.qubership.integration.platform.runtime.catalog.rest.v1.dto.system.mcp.MCPSystemFilterRequestDTO; +import org.qubership.integration.platform.runtime.catalog.rest.v1.dto.system.mcp.MCPSystemRequestDTO; +import org.qubership.integration.platform.runtime.catalog.rest.v1.dto.system.mcp.MCPSystemResponseDTO; +import org.qubership.integration.platform.runtime.catalog.rest.v1.mapper.MCPSystemMapper; +import org.qubership.integration.platform.runtime.catalog.service.MCPSystemService; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.MCPSystemImportExportService; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; +import java.util.Optional; + +import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class MCPSystemControllerTest { + + private static final String BASE_URL = "/v1/catalog/mcp-system"; + private static final String SYSTEM_ID = "system-id-1"; + + @Mock + MCPSystemService mcpSystemService; + + @Mock + MCPSystemMapper mcpSystemMapper; + + @Mock + MCPSystemImportExportService mcpSystemImportExportService; + + @InjectMocks + MCPSystemController mcpSystemController; + + MockMvc mockMvc; + ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(mcpSystemController).build(); + objectMapper = new ObjectMapper(); + } + + // GET /v1/catalog/mcp-system + + @Test + @DisplayName("GET /v1/catalog/mcp-system returns 200 with list of systems") + void getAllReturns200WithDtos() throws Exception { + MCPSystem system = MCPSystem.builder().id(SYSTEM_ID).name("s1").build(); + MCPSystemResponseDTO dto = new MCPSystemResponseDTO(); + dto.setId(SYSTEM_ID); + dto.setName("s1"); + + when(mcpSystemService.findAll(false)).thenReturn(List.of(system)); + when(mcpSystemMapper.toResponseDtos(List.of(system))).thenReturn(List.of(dto)); + + mockMvc.perform(get(BASE_URL)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].id", is(SYSTEM_ID))); + } + + @Test + @DisplayName("GET /v1/catalog/mcp-system?withChains=true passes withChains=true to service") + void getAllWithChainsFlagPassesTrueToService() throws Exception { + when(mcpSystemService.findAll(true)).thenReturn(List.of()); + when(mcpSystemMapper.toResponseDtos(any())).thenReturn(List.of()); + + mockMvc.perform(get(BASE_URL).param("withChains", "true")) + .andExpect(status().isOk()); + + verify(mcpSystemService).findAll(true); + } + + // GET /v1/catalog/mcp-system/{id} + + @Test + @DisplayName("GET /v1/catalog/mcp-system/{id} returns 200 when system found") + void getByIdReturns200WhenFound() throws Exception { + MCPSystem system = MCPSystem.builder().id(SYSTEM_ID).build(); + MCPSystemResponseDTO dto = new MCPSystemResponseDTO(); + dto.setId(SYSTEM_ID); + + when(mcpSystemService.findById(SYSTEM_ID)).thenReturn(Optional.of(system)); + when(mcpSystemMapper.toResponseDto(system)).thenReturn(dto); + + mockMvc.perform(get(BASE_URL + "/{id}", SYSTEM_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(SYSTEM_ID))); + } + + @Test + @DisplayName("GET /v1/catalog/mcp-system/{id} returns 404 when system not found") + void getByIdReturns404WhenNotFound() throws Exception { + when(mcpSystemService.findById(SYSTEM_ID)).thenReturn(Optional.empty()); + + mockMvc.perform(get(BASE_URL + "/{id}", SYSTEM_ID)) + .andExpect(status().isNotFound()); + } + + // POST /v1/catalog/mcp-system + + @Test + @DisplayName("POST /v1/catalog/mcp-system returns 201 with created system DTO") + void createReturns201WithDto() throws Exception { + MCPSystemRequestDTO request = new MCPSystemRequestDTO(); + request.setName("new-system"); + + MCPSystem created = MCPSystem.builder().id(SYSTEM_ID).name("new-system").build(); + MCPSystemResponseDTO dto = new MCPSystemResponseDTO(); + dto.setId(SYSTEM_ID); + dto.setName("new-system"); + + when(mcpSystemService.create(any(MCPSystemRequestDTO.class))).thenReturn(created); + when(mcpSystemMapper.toResponseDto(created)).thenReturn(dto); + + mockMvc.perform(post(BASE_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id", is(SYSTEM_ID))) + .andExpect(jsonPath("$.name", is("new-system"))); + } + + // PUT /v1/catalog/mcp-system/{id} + + @Test + @DisplayName("PUT /v1/catalog/mcp-system/{id} returns 200 with updated system DTO") + void updateReturns200WithDto() throws Exception { + MCPSystemRequestDTO request = new MCPSystemRequestDTO(); + request.setName("updated-name"); + + MCPSystem updated = MCPSystem.builder().id(SYSTEM_ID).name("updated-name").build(); + MCPSystemResponseDTO dto = new MCPSystemResponseDTO(); + dto.setId(SYSTEM_ID); + dto.setName("updated-name"); + + when(mcpSystemService.update(eq(SYSTEM_ID), any(MCPSystemRequestDTO.class))).thenReturn(updated); + when(mcpSystemMapper.toResponseDto(updated)).thenReturn(dto); + + mockMvc.perform(put(BASE_URL + "/{id}", SYSTEM_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(SYSTEM_ID))) + .andExpect(jsonPath("$.name", is("updated-name"))); + } + + // DELETE /v1/catalog/mcp-system/{id} + + @Test + @DisplayName("DELETE /v1/catalog/mcp-system/{id} returns 204") + void deleteReturns204() throws Exception { + doNothing().when(mcpSystemService).deleteById(SYSTEM_ID); + + mockMvc.perform(delete(BASE_URL + "/{id}", SYSTEM_ID)) + .andExpect(status().isNoContent()); + + verify(mcpSystemService).deleteById(SYSTEM_ID); + } + + // POST /v1/catalog/mcp-system/filter + + @Test + @DisplayName("POST /v1/catalog/mcp-system/filter returns 200 with filtered results") + void filterReturns200WithResults() throws Exception { + MCPSystemFilterRequestDTO filterRequest = new MCPSystemFilterRequestDTO(); + filterRequest.setSearchString("test"); + filterRequest.setFilters(List.of()); + + MCPSystem system = MCPSystem.builder().id(SYSTEM_ID).build(); + MCPSystemResponseDTO dto = new MCPSystemResponseDTO(); + dto.setId(SYSTEM_ID); + + when(mcpSystemService.filter(eq("test"), any())).thenReturn(List.of(system)); + when(mcpSystemMapper.toResponseDtos(List.of(system))).thenReturn(List.of(dto)); + + mockMvc.perform(post(BASE_URL + "/filter") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(filterRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].id", is(SYSTEM_ID))); + } + + // POST /v1/catalog/mcp-system/export + + @Test + @DisplayName("POST /v1/catalog/mcp-system/export returns 200 with attachment when data present") + void exportReturns200WithAttachmentWhenDataPresent() throws Exception { + byte[] data = new byte[]{1, 2, 3}; + when(mcpSystemImportExportService.export(any())).thenReturn(data); + + mockMvc.perform(post(BASE_URL + "/export") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(List.of(SYSTEM_ID)))) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", containsString("attachment"))); + } + + @Test + @DisplayName("POST /v1/catalog/mcp-system/export returns 204 when no data") + void exportReturns204WhenNoData() throws Exception { + when(mcpSystemImportExportService.export(any())).thenReturn(null); + + mockMvc.perform(post(BASE_URL + "/export") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(List.of()))) + .andExpect(status().isNoContent()); + } + + // POST /v1/catalog/mcp-system/import + + @Test + @DisplayName("POST /v1/catalog/mcp-system/import returns 200 when all results are OK") + void importSystemsReturns200WhenAllOk() throws Exception { + ImportSystemResult result = ImportSystemResult.builder() + .id(SYSTEM_ID) + .status(ImportSystemStatus.CREATED) + .build(); + + when(mcpSystemImportExportService.importSystems(any(), any())).thenReturn(List.of(result)); + + MockMultipartFile file = new MockMultipartFile("file", "systems.zip", + MediaType.APPLICATION_OCTET_STREAM_VALUE, new byte[]{1, 2, 3}); + + mockMvc.perform(multipart(BASE_URL + "/import").file(file)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].id", is(SYSTEM_ID))); + } + + @Test + @DisplayName("POST /v1/catalog/mcp-system/import returns 207 when some results have errors") + void importSystemsReturns207WhenSomeErrors() throws Exception { + ImportSystemResult ok = ImportSystemResult.builder() + .id("id-1").status(ImportSystemStatus.CREATED).build(); + ImportSystemResult error = ImportSystemResult.builder() + .id("id-2").status(ImportSystemStatus.ERROR).build(); + + when(mcpSystemImportExportService.importSystems(any(), any())).thenReturn(List.of(ok, error)); + + MockMultipartFile file = new MockMultipartFile("file", "systems.zip", + MediaType.APPLICATION_OCTET_STREAM_VALUE, new byte[]{1, 2, 3}); + + mockMvc.perform(multipart(BASE_URL + "/import").file(file)) + .andExpect(status().isMultiStatus()); + } + + @Test + @DisplayName("POST /v1/catalog/mcp-system/import returns 204 when result list is empty") + void importSystemsReturns204WhenEmpty() throws Exception { + when(mcpSystemImportExportService.importSystems(any(), any())).thenReturn(List.of()); + + MockMultipartFile file = new MockMultipartFile("file", "systems.zip", + MediaType.APPLICATION_OCTET_STREAM_VALUE, new byte[]{1, 2, 3}); + + mockMvc.perform(multipart(BASE_URL + "/import").file(file)) + .andExpect(status().isNoContent()); + } + + // POST /v1/catalog/mcp-system/import/preview + + @Test + @DisplayName("POST /v1/catalog/mcp-system/import/preview returns 200 with preview results") + void getImportPreviewReturns200WhenResultsPresent() throws Exception { + ImportSystemResult result = ImportSystemResult.builder() + .id(SYSTEM_ID).status(ImportSystemStatus.CREATED).build(); + + when(mcpSystemImportExportService.getImportPreview(any())).thenReturn(List.of(result)); + + MockMultipartFile file = new MockMultipartFile("file", "systems.zip", + MediaType.APPLICATION_OCTET_STREAM_VALUE, new byte[]{1, 2, 3}); + + mockMvc.perform(multipart(BASE_URL + "/import/preview").file(file)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].id", is(SYSTEM_ID))); + } + + @Test + @DisplayName("POST /v1/catalog/mcp-system/import/preview returns 204 when no preview results") + void getImportPreviewReturns204WhenEmpty() throws Exception { + when(mcpSystemImportExportService.getImportPreview(any())).thenReturn(List.of()); + + MockMultipartFile file = new MockMultipartFile("file", "systems.zip", + MediaType.APPLICATION_OCTET_STREAM_VALUE, new byte[]{1, 2, 3}); + + mockMvc.perform(multipart(BASE_URL + "/import/preview").file(file)) + .andExpect(status().isNoContent()); + } +} diff --git a/src/test/java/org/qubership/integration/platform/runtime/catalog/service/MCPSystemServiceTest.java b/src/test/java/org/qubership/integration/platform/runtime/catalog/service/MCPSystemServiceTest.java new file mode 100644 index 00000000..22061cf3 --- /dev/null +++ b/src/test/java/org/qubership/integration/platform/runtime/catalog/service/MCPSystemServiceTest.java @@ -0,0 +1,396 @@ +/* + * Copyright 2024-2025 NetCracker Technology Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.qubership.integration.platform.runtime.catalog.service; + +import jakarta.persistence.EntityNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.junit.jupiter.MockitoExtension; +import org.qubership.integration.platform.runtime.catalog.exception.exceptions.SystemDeleteException; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.actionlog.ActionLog; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.actionlog.EntityType; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.actionlog.LogOperation; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.chain.Chain; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.mcp.MCPSystem; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.mcp.MCPSystemLabel; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.repository.chain.ChainRepository; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.repository.mcp.MCPSystemRepository; +import org.qubership.integration.platform.runtime.catalog.rest.v1.dto.system.mcp.MCPSystemRequestDTO; +import org.qubership.integration.platform.runtime.catalog.rest.v1.mapper.MCPSystemMapper; +import org.qubership.integration.platform.runtime.catalog.service.filter.MCPSystemFilterSpecificationBuilder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ContextConfiguration(classes = MCPSystemService.class) +@ExtendWith(SpringExtension.class) +@ExtendWith(MockitoExtension.class) +public class MCPSystemServiceTest { + + private static final String SYSTEM_ID = "system-id-1"; + private static final String SYSTEM_NAME = "Test MCP System"; + private static final String MCP_SERVICE_IDS_PROPERTY = "mcpServiceIds"; + + @MockitoBean + MCPSystemRepository mcpSystemRepository; + + @MockitoBean + MCPSystemMapper mcpSystemMapper; + + @MockitoBean + ActionsLogService actionLogger; + + @MockitoBean + MCPSystemFilterSpecificationBuilder mcpSystemFilterSpecificationBuilder; + + @MockitoBean + ChainRepository chainRepository; + + @Captor + ArgumentCaptor actionLogCaptor; + + @Autowired + MCPSystemService mcpSystemService; + + private MCPSystem mcpSystem; + + @BeforeEach + void setUp() { + mcpSystem = MCPSystem.builder() + .id(SYSTEM_ID) + .name(SYSTEM_NAME) + .build(); + } + + // findAll tests + + @Test + @DisplayName("findAll without chains returns repository result") + void findAllWithoutChainsReturnsRepositoryResult() { + List systems = List.of(mcpSystem); + when(mcpSystemRepository.findAll()).thenReturn(systems); + + List result = mcpSystemService.findAll(false); + + assertThat(result, equalTo(systems)); + verifyNoInteractions(chainRepository); + } + + @Test + @DisplayName("findAll with chains enriches each system with chain list") + void findAllWithChainsEnrichesWithChains() { + List systems = List.of(mcpSystem); + List chains = List.of(Chain.builder().id("chain-1").build()); + when(mcpSystemRepository.findAll()).thenReturn(systems); + when(chainRepository.findChainsWithElementPropertyContainsValue(MCP_SERVICE_IDS_PROPERTY, SYSTEM_ID)) + .thenReturn(chains); + + List result = mcpSystemService.findAll(true); + + assertThat(result.get(0).getChains(), equalTo(chains)); + } + + @Test + @DisplayName("findAll default overload delegates to findAll(false)") + void findAllDefaultDelegatesToFindAllWithoutChains() { + when(mcpSystemRepository.findAll()).thenReturn(List.of()); + + mcpSystemService.findAll(); + + verify(mcpSystemRepository).findAll(); + verifyNoInteractions(chainRepository); + } + + // findAllById tests + + @Test + @DisplayName("findAllById delegates to repository") + void findAllByIdDelegatesToRepository() { + List ids = List.of(SYSTEM_ID); + List systems = List.of(mcpSystem); + when(mcpSystemRepository.findAllById(ids)).thenReturn(systems); + + List result = mcpSystemService.findAllById(ids); + + assertThat(result, equalTo(systems)); + } + + // findById tests + + @Test + @DisplayName("findById returns optional from repository") + void findByIdReturnsOptionalFromRepository() { + when(mcpSystemRepository.findById(SYSTEM_ID)).thenReturn(Optional.of(mcpSystem)); + + Optional result = mcpSystemService.findById(SYSTEM_ID); + + assertTrue(result.isPresent()); + assertThat(result.get(), equalTo(mcpSystem)); + } + + @Test + @DisplayName("findById returns empty optional when not found") + void findByIdReturnsEmptyWhenNotFound() { + when(mcpSystemRepository.findById(SYSTEM_ID)).thenReturn(Optional.empty()); + + Optional result = mcpSystemService.findById(SYSTEM_ID); + + assertFalse(result.isPresent()); + } + + // create(MCPSystemRequestDTO) tests + + @Test + @DisplayName("create from request saves system with mapped fields") + void createFromRequestSavesSystemWithMappedFields() { + MCPSystemRequestDTO request = new MCPSystemRequestDTO(); + request.setName("name"); + request.setDescription("desc"); + request.setIdentifier("ident"); + request.setInstructions("instruc"); + + when(mcpSystemRepository.save(any(MCPSystem.class))).thenAnswer(inv -> inv.getArgument(0)); + + MCPSystem result = mcpSystemService.create(request); + + assertThat(result.getName(), equalTo("name")); + assertThat(result.getDescription(), equalTo("desc")); + assertThat(result.getIdentifier(), equalTo("ident")); + assertThat(result.getInstructions(), equalTo("instruc")); + } + + // create(MCPSystem, boolean) tests + + @Test + @DisplayName("create with isImport=false logs CREATE operation") + void createWithIsImportFalseLogsCreate() { + when(mcpSystemRepository.save(mcpSystem)).thenReturn(mcpSystem); + + mcpSystemService.create(mcpSystem, false); + + verify(actionLogger).logAction(actionLogCaptor.capture()); + ActionLog log = actionLogCaptor.getValue(); + assertThat(log.getOperation(), equalTo(LogOperation.CREATE)); + assertThat(log.getEntityType(), equalTo(EntityType.MCP_SYSTEM)); + assertThat(log.getEntityId(), equalTo(SYSTEM_ID)); + assertThat(log.getEntityName(), equalTo(SYSTEM_NAME)); + } + + @Test + @DisplayName("create with isImport=true logs IMPORT operation") + void createWithIsImportTrueLogsImport() { + when(mcpSystemRepository.save(mcpSystem)).thenReturn(mcpSystem); + + mcpSystemService.create(mcpSystem, true); + + verify(actionLogger).logAction(actionLogCaptor.capture()); + assertThat(actionLogCaptor.getValue().getOperation(), equalTo(LogOperation.IMPORT)); + } + + // update(String, MCPSystemRequestDTO) tests + + @Test + @DisplayName("update throws EntityNotFoundException when system not found") + void updateThrowsEntityNotFoundWhenSystemNotFound() { + when(mcpSystemRepository.findById(SYSTEM_ID)).thenReturn(Optional.empty()); + + MCPSystemRequestDTO request = new MCPSystemRequestDTO(); + request.setLabels(List.of()); + + assertThrows(EntityNotFoundException.class, () -> mcpSystemService.update(SYSTEM_ID, request)); + } + + @Test + @DisplayName("update saves system and logs UPDATE operation") + void updateSavesAndLogsUpdate() { + MCPSystemRequestDTO request = new MCPSystemRequestDTO(); + request.setLabels(List.of()); + + when(mcpSystemRepository.findById(SYSTEM_ID)).thenReturn(Optional.of(mcpSystem)); + when(mcpSystemMapper.updateWithoutLabels(eq(mcpSystem), eq(request))).thenReturn(mcpSystem); + when(mcpSystemRepository.save(mcpSystem)).thenReturn(mcpSystem); + + MCPSystem result = mcpSystemService.update(SYSTEM_ID, request); + + assertThat(result, equalTo(mcpSystem)); + verify(actionLogger).logAction(actionLogCaptor.capture()); + assertThat(actionLogCaptor.getValue().getOperation(), equalTo(LogOperation.UPDATE)); + } + + // update(MCPSystem) tests + + @Test + @DisplayName("update(MCPSystem) saves system and logs UPDATE") + void updateMcpSystemSavesAndLogsUpdate() { + when(mcpSystemRepository.save(mcpSystem)).thenReturn(mcpSystem); + + MCPSystem result = mcpSystemService.update(mcpSystem); + + assertThat(result, equalTo(mcpSystem)); + verify(actionLogger).logAction(actionLogCaptor.capture()); + assertThat(actionLogCaptor.getValue().getOperation(), equalTo(LogOperation.UPDATE)); + } + + // updateLabels tests + + @Test + @DisplayName("updateLabels adds new labels not present in current set") + void updateLabelsAddsNewLabels() { + Set currentLabels = new LinkedHashSet<>(); + MCPSystemLabel newLabel = new MCPSystemLabel("new-label", false, mcpSystem); + List newLabels = List.of(newLabel); + + mcpSystemService.updateLabels(mcpSystem, currentLabels, newLabels); + + assertThat(currentLabels, hasSize(1)); + assertThat(currentLabels.iterator().next().getName(), equalTo("new-label")); + } + + @Test + @DisplayName("updateLabels removes labels absent from new list") + void updateLabelsRemovesStaleLabels() { + MCPSystemLabel existing = new MCPSystemLabel("old-label", false, mcpSystem); + Set currentLabels = new LinkedHashSet<>(List.of(existing)); + + mcpSystemService.updateLabels(mcpSystem, currentLabels, List.of()); + + assertThat(currentLabels, empty()); + } + + @Test + @DisplayName("updateLabels updates technical flag of existing labels") + void updateLabelsUpdatesTechnicalFlag() { + MCPSystemLabel existing = new MCPSystemLabel("label", false, mcpSystem); + Set currentLabels = new LinkedHashSet<>(List.of(existing)); + + MCPSystemLabel updated = new MCPSystemLabel("label", true, mcpSystem); + mcpSystemService.updateLabels(mcpSystem, currentLabels, List.of(updated)); + + assertThat(currentLabels, hasSize(1)); + assertTrue(currentLabels.iterator().next().isTechnical()); + } + + @Test + @DisplayName("updateLabels sets system reference on newly added labels") + void updateLabelsSetsSystemOnNewLabels() { + Set currentLabels = new LinkedHashSet<>(); + MCPSystemLabel newLabel = new MCPSystemLabel(); + newLabel.setName("brand-new"); + + mcpSystemService.updateLabels(mcpSystem, currentLabels, List.of(newLabel)); + + assertThat(currentLabels.iterator().next().getSystem(), equalTo(mcpSystem)); + } + + // deleteById tests + + @Test + @DisplayName("deleteById throws SystemDeleteException when system is used by a chain") + void deleteByIdThrowsWhenUsedByChain() { + when(chainRepository.findChainsWithElementPropertyContainsValue(MCP_SERVICE_IDS_PROPERTY, SYSTEM_ID)) + .thenReturn(List.of(Chain.builder().id("chain-1").build())); + + assertThrows(SystemDeleteException.class, () -> mcpSystemService.deleteById(SYSTEM_ID)); + verify(mcpSystemRepository, never()).delete(any(MCPSystem.class)); + } + + @Test + @DisplayName("deleteById deletes system and logs DELETE when not used by chains") + void deleteByIdDeletesAndLogsWhenNotUsedByChain() { + when(chainRepository.findChainsWithElementPropertyContainsValue(MCP_SERVICE_IDS_PROPERTY, SYSTEM_ID)) + .thenReturn(List.of()); + when(mcpSystemRepository.findById(SYSTEM_ID)).thenReturn(Optional.of(mcpSystem)); + + mcpSystemService.deleteById(SYSTEM_ID); + + verify(mcpSystemRepository).delete(mcpSystem); + verify(actionLogger).logAction(actionLogCaptor.capture()); + assertThat(actionLogCaptor.getValue().getOperation(), equalTo(LogOperation.DELETE)); + } + + @Test + @DisplayName("deleteById does nothing when system not found in repository") + void deleteByIdDoesNothingWhenNotFound() { + when(chainRepository.findChainsWithElementPropertyContainsValue(MCP_SERVICE_IDS_PROPERTY, SYSTEM_ID)) + .thenReturn(List.of()); + when(mcpSystemRepository.findById(SYSTEM_ID)).thenReturn(Optional.empty()); + + mcpSystemService.deleteById(SYSTEM_ID); + + verify(mcpSystemRepository, never()).delete(any(MCPSystem.class)); + verifyNoInteractions(actionLogger); + } + + // filter tests + + @Test + @DisplayName("filter builds specification and enriches result with chains") + void filterBuildsSpecificationAndEnrichesWithChains() { + Specification spec = mock(Specification.class); + List systems = new ArrayList<>(List.of(mcpSystem)); + + when(mcpSystemFilterSpecificationBuilder.buildSearchAndFilters(eq("search"), any())) + .thenReturn(spec); + when(mcpSystemRepository.findAll(spec)).thenReturn(systems); + when(chainRepository.findChainsWithElementPropertyContainsValue(MCP_SERVICE_IDS_PROPERTY, SYSTEM_ID)) + .thenReturn(List.of()); + + List result = mcpSystemService.filter("search", List.of()); + + assertThat(result, hasSize(1)); + verify(mcpSystemFilterSpecificationBuilder).buildSearchAndFilters(eq("search"), any()); + } + + // isUsedByChain tests + + @Test + @DisplayName("isUsedByChain returns true when chains reference the system") + void isUsedByChainReturnsTrueWhenChainExists() { + when(chainRepository.findChainsWithElementPropertyContainsValue(MCP_SERVICE_IDS_PROPERTY, SYSTEM_ID)) + .thenReturn(List.of(Chain.builder().id("chain-1").build())); + + assertTrue(mcpSystemService.isUsedByChain(SYSTEM_ID)); + } + + @Test + @DisplayName("isUsedByChain returns false when no chains reference the system") + void isUsedByChainReturnsFalseWhenNoChains() { + when(chainRepository.findChainsWithElementPropertyContainsValue(MCP_SERVICE_IDS_PROPERTY, SYSTEM_ID)) + .thenReturn(List.of()); + + assertFalse(mcpSystemService.isUsedByChain(SYSTEM_ID)); + } +} diff --git a/src/test/java/org/qubership/integration/platform/runtime/catalog/service/deployment/properties/builders/McpTriggerPropertiesBuilderTest.java b/src/test/java/org/qubership/integration/platform/runtime/catalog/service/deployment/properties/builders/McpTriggerPropertiesBuilderTest.java new file mode 100644 index 00000000..9b3c1101 --- /dev/null +++ b/src/test/java/org/qubership/integration/platform/runtime/catalog/service/deployment/properties/builders/McpTriggerPropertiesBuilderTest.java @@ -0,0 +1,168 @@ +/* + * Copyright 2024-2025 NetCracker Technology Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.qubership.integration.platform.runtime.catalog.service.deployment.properties.builders; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.chain.element.ChainElement; + +import java.util.HashMap; +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.*; + +class McpTriggerPropertiesBuilderTest { + + private McpTriggerPropertiesBuilder builder; + + @BeforeEach + void setUp() { + builder = new McpTriggerPropertiesBuilder(); + } + + // applicableTo tests + + @Test + @DisplayName("applicableTo returns true for mcp-trigger element type") + void applicableToMcpTriggerTypeReturnsTrue() { + ChainElement element = ChainElement.builder().type("mcp-trigger").build(); + assertTrue(builder.applicableTo(element)); + } + + @Test + @DisplayName("applicableTo returns false for non-mcp-trigger element type") + void applicableToOtherTypeReturnsFalse() { + ChainElement element = ChainElement.builder().type("http-trigger").build(); + assertFalse(builder.applicableTo(element)); + } + + @Test + @DisplayName("applicableTo returns false for null element type") + void applicableToNullTypeReturnsFalse() { + ChainElement element = ChainElement.builder().build(); + assertFalse(builder.applicableTo(element)); + } + + // build tests + + @Test + @DisplayName("build returns all expected keys") + void buildAllExpectedKeysPresent() { + ChainElement element = ChainElement.builder().type("mcp-trigger").build(); + + Map result = builder.build(element); + + assertThat(result.keySet(), containsInAnyOrder( + "mcpServiceIds", "name", "title", "description", + "inputSchema", "outputSchema", "readOnly", "destructive", + "idempotent", "openWorld", "requiresLocal" + )); + } + + @Test + @DisplayName("build maps element properties to string values") + void buildWithAllPropertiesMapsCorrectly() { + Map properties = new HashMap<>(); + properties.put("mcpServiceIds", "service-123"); + properties.put("name", "my-tool"); + properties.put("title", "My Tool"); + properties.put("description", "Does something"); + properties.put("inputSchema", "{\"type\":\"object\"}"); + properties.put("outputSchema", "{\"type\":\"string\"}"); + properties.put("readOnly", true); + properties.put("destructive", false); + properties.put("idempotent", true); + properties.put("openWorld", false); + properties.put("requiresLocal", true); + + ChainElement element = ChainElement.builder() + .type("mcp-trigger") + .properties(properties) + .build(); + + Map result = builder.build(element); + + assertThat(result, allOf( + hasEntry("mcpServiceIds", "service-123"), + hasEntry("name", "my-tool"), + hasEntry("title", "My Tool"), + hasEntry("description", "Does something"), + hasEntry("inputSchema", "{\"type\":\"object\"}"), + hasEntry("outputSchema", "{\"type\":\"string\"}"), + hasEntry("readOnly", "true"), + hasEntry("destructive", "false"), + hasEntry("idempotent", "true"), + hasEntry("openWorld", "false"), + hasEntry("requiresLocal", "true") + )); + } + + @Test + @DisplayName("build returns empty string for missing properties") + void buildWithNoPropertiesReturnsEmptyStrings() { + ChainElement element = ChainElement.builder().type("mcp-trigger").build(); + + Map result = builder.build(element); + + assertThat(result.values(), everyItem(equalTo(""))); + } + + @Test + @DisplayName("build returns empty string for null property value") + void buildWithNullPropertyValueReturnsEmptyString() { + Map properties = new HashMap<>(); + properties.put("name", null); + + ChainElement element = ChainElement.builder() + .type("mcp-trigger") + .properties(properties) + .build(); + + Map result = builder.build(element); + + assertThat(result, hasEntry("name", "")); + } + + @Test + @DisplayName("build converts non-string property values using toString") + void buildWithIntegerPropertyValueConvertsToString() { + Map properties = new HashMap<>(); + properties.put("mcpServiceIds", 42); + + ChainElement element = ChainElement.builder() + .type("mcp-trigger") + .properties(properties) + .build(); + + Map result = builder.build(element); + + assertThat(result, hasEntry("mcpServiceIds", "42")); + } + + @Test + @DisplayName("build returns exactly 11 entries") + void buildReturnsExactly11Entries() { + ChainElement element = ChainElement.builder().type("mcp-trigger").build(); + + Map result = builder.build(element); + + assertThat(result.size(), equalTo(11)); + } +} diff --git a/src/test/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/MCPSystemImportExportServiceTest.java b/src/test/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/MCPSystemImportExportServiceTest.java new file mode 100644 index 00000000..05bc8789 --- /dev/null +++ b/src/test/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/MCPSystemImportExportServiceTest.java @@ -0,0 +1,289 @@ +/* + * Copyright 2024-2025 NetCracker Technology Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.qubership.integration.platform.runtime.catalog.service.exportimport; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.qubership.integration.platform.runtime.catalog.model.exportimport.system.ImportSystemResult; +import org.qubership.integration.platform.runtime.catalog.model.system.exportimport.ExportedSystemObject; +import org.qubership.integration.platform.runtime.catalog.persistence.configs.entity.mcp.MCPSystem; +import org.qubership.integration.platform.runtime.catalog.service.ActionsLogService; +import org.qubership.integration.platform.runtime.catalog.service.MCPSystemService; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.deserializer.MCPSystemDeserializer; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.instructions.ImportInstructionsService; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.serializer.ArchiveWriter; +import org.qubership.integration.platform.runtime.catalog.service.exportimport.serializer.MCPSystemSerializer; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.transaction.support.TransactionTemplate; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MCPSystemImportExportServiceTest { + + private static final String SYSTEM_ID = "system-id-1"; + private static final String SYSTEM_NAME = "Test System"; + private static final byte[] ARCHIVE_BYTES = new byte[]{1, 2, 3}; + + @Mock TransactionTemplate transactionTemplate; + @Mock YAMLMapper yamlMapper; + @Mock MCPSystemService mcpSystemService; + @Mock ActionsLogService actionLogger; + @Mock MCPSystemSerializer mcpSystemSerializer; + @Mock MCPSystemDeserializer mcpSystemDeserializer; + @Mock ArchiveWriter archiveWriter; + @Mock ImportInstructionsService importInstructionsService; + @Mock ImportSessionService importProgressService; + + @Captor ArgumentCaptor> exportedSystemsCaptor; + + MCPSystemImportExportService service; + + @BeforeEach + void setUp() throws Exception { + service = new MCPSystemImportExportService( + transactionTemplate, + yamlMapper, + mcpSystemService, + actionLogger, + mcpSystemSerializer, + mcpSystemDeserializer, + archiveWriter, + importInstructionsService, + importProgressService, + new URI("http://qubership.org/schemas/product/qip/mcp-service") + ); + } + + // export tests + + @Test + @DisplayName("export with null ids calls findAll()") + void exportWithNullIdsFindAll() throws JsonProcessingException { + MCPSystem system = buildSystem(); + ExportedSystemObject exported = mock(ExportedSystemObject.class); + when(mcpSystemService.findAll()).thenReturn(List.of(system)); + when(mcpSystemSerializer.serialize(system)).thenReturn(exported); + when(archiveWriter.writeArchive(anyList())).thenReturn(ARCHIVE_BYTES); + + service.export(null); + + verify(mcpSystemService).findAll(); + verify(mcpSystemService, never()).findAllById(any()); + } + + @Test + @DisplayName("export with ids list calls findAllById()") + void exportWithIdsCallsFindAllById() throws JsonProcessingException { + List ids = List.of(SYSTEM_ID); + MCPSystem system = buildSystem(); + ExportedSystemObject exported = mock(ExportedSystemObject.class); + when(mcpSystemService.findAllById(ids)).thenReturn(List.of(system)); + when(mcpSystemSerializer.serialize(system)).thenReturn(exported); + when(archiveWriter.writeArchive(anyList())).thenReturn(ARCHIVE_BYTES); + + service.export(ids); + + verify(mcpSystemService).findAllById(ids); + verify(mcpSystemService, never()).findAll(); + } + + @Test + @DisplayName("export returns null when no systems found") + void exportReturnsNullWhenNoSystems() { + when(mcpSystemService.findAll()).thenReturn(List.of()); + + byte[] result = service.export(null); + + assertNull(result); + verifyNoInteractions(mcpSystemSerializer, archiveWriter); + } + + @Test + @DisplayName("export returns archive bytes when systems found") + void exportReturnsBytesFromArchiveWriter() throws JsonProcessingException { + MCPSystem system = buildSystem(); + ExportedSystemObject exported = mock(ExportedSystemObject.class); + when(mcpSystemService.findAll()).thenReturn(List.of(system)); + when(mcpSystemSerializer.serialize(system)).thenReturn(exported); + when(archiveWriter.writeArchive(anyList())).thenReturn(ARCHIVE_BYTES); + + byte[] result = service.export(null); + + assertThat(result, equalTo(ARCHIVE_BYTES)); + } + + @Test + @DisplayName("export serializes all systems and passes them to archiveWriter") + void exportSerializesAllSystems() throws JsonProcessingException { + MCPSystem s1 = buildSystem("id-1", "sys-1"); + MCPSystem s2 = buildSystem("id-2", "sys-2"); + ExportedSystemObject e1 = mock(ExportedSystemObject.class); + ExportedSystemObject e2 = mock(ExportedSystemObject.class); + when(mcpSystemService.findAll()).thenReturn(List.of(s1, s2)); + when(mcpSystemSerializer.serialize(s1)).thenReturn(e1); + when(mcpSystemSerializer.serialize(s2)).thenReturn(e2); + when(archiveWriter.writeArchive(exportedSystemsCaptor.capture())).thenReturn(ARCHIVE_BYTES); + + service.export(null); + + List passed = exportedSystemsCaptor.getValue(); + assertThat(passed, containsInAnyOrder(e1, e2)); + } + + @Test + @DisplayName("export logs EXPORT action for each exported system") + void exportLogsExportForEachSystem() throws JsonProcessingException { + MCPSystem s1 = buildSystem("id-1", "sys-1"); + MCPSystem s2 = buildSystem("id-2", "sys-2"); + ExportedSystemObject e1 = mock(ExportedSystemObject.class); + ExportedSystemObject e2 = mock(ExportedSystemObject.class); + when(mcpSystemService.findAll()).thenReturn(List.of(s1, s2)); + when(mcpSystemSerializer.serialize(s1)).thenReturn(e1); + when(mcpSystemSerializer.serialize(s2)).thenReturn(e2); + when(archiveWriter.writeArchive(anyList())).thenReturn(ARCHIVE_BYTES); + + service.export(null); + + verify(actionLogger, times(2)).logAction(any()); + } + + @Test + @DisplayName("export throws RuntimeException when serialization fails") + void exportThrowsWhenSerializationFails() throws JsonProcessingException { + MCPSystem system = buildSystem(); + when(mcpSystemService.findAll()).thenReturn(List.of(system)); + when(mcpSystemSerializer.serialize(system)).thenThrow(mock(JsonProcessingException.class)); + + RuntimeException ex = assertThrows(RuntimeException.class, () -> service.export(null)); + + assertThat(ex.getMessage(), containsString("Failed to export system")); + assertThat(ex.getMessage(), containsString(SYSTEM_NAME)); + assertThat(ex.getMessage(), containsString(SYSTEM_ID)); + } + + // importSystems(MultipartFile, List) error paths + + @Test + @DisplayName("importSystems throws RuntimeException for non-zip file extension") + void importSystemsThrowsForNonZipExtension() { + MockMultipartFile file = new MockMultipartFile( + "file", "systems.yaml", "application/octet-stream", new byte[]{1}); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> service.importSystems(file, null)); + + assertThat(ex.getMessage(), containsString("Unsupported file extension")); + assertThat(ex.getMessage(), containsString("yaml")); + } + + @Test + @DisplayName("importSystems throws RuntimeException for file with no extension") + void importSystemsThrowsForMissingExtension() { + MockMultipartFile file = new MockMultipartFile( + "file", "systems", "application/octet-stream", new byte[]{1}); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> service.importSystems(file, null)); + + assertThat(ex.getMessage(), containsString("Unsupported file extension")); + } + + @Test + @DisplayName("importSystems accepts zip file (case-insensitive extension)") + void importSystemsAcceptsZipCaseInsensitive() throws Exception { + byte[] zipBytes = buildEmptyZip(); + MockMultipartFile file = new MockMultipartFile( + "file", "systems.ZIP", "application/octet-stream", zipBytes); + + when(importInstructionsService.performServiceIgnoreInstructions(any(), anyBoolean())) + .thenReturn(new org.qubership.integration.platform.runtime.catalog.model.exportimport.instructions.IgnoreResult( + java.util.Set.of(), List.of())); + + List result = service.importSystems(file, null); + + assertThat(result, empty()); + } + + // getImportPreview(MultipartFile) error paths + + @Test + @DisplayName("getImportPreview throws RuntimeException for non-zip file extension") + void getImportPreviewThrowsForNonZipExtension() { + MockMultipartFile file = new MockMultipartFile( + "file", "systems.yaml", "application/octet-stream", new byte[]{1}); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> service.getImportPreview(file)); + + assertThat(ex.getMessage(), containsString("Unsupported file extension")); + } + + @Test + @DisplayName("getImportPreview returns empty list for empty zip") + void getImportPreviewReturnsEmptyListForEmptyZip() throws Exception { + byte[] zipBytes = buildEmptyZip(); + MockMultipartFile file = new MockMultipartFile( + "file", "systems.zip", "application/octet-stream", zipBytes); + + when(importInstructionsService.getServiceImportInstructionsConfig(any())) + .thenReturn(new org.qubership.integration.platform.runtime.catalog.model.exportimport.instructions.ImportInstructionsConfig()); + + List result = service.getImportPreview(file); + + assertThat(result, empty()); + } + + // helpers + + private MCPSystem buildSystem() { + return buildSystem(SYSTEM_ID, SYSTEM_NAME); + } + + private MCPSystem buildSystem(String id, String name) { + return MCPSystem.builder().id(id).name(name).build(); + } + + private byte[] buildEmptyZip() throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + zos.putNextEntry(new ZipEntry("placeholder/")); + zos.closeEntry(); + } + return baos.toByteArray(); + } +} diff --git a/src/test/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/serializer/ArchiveWriterTest.java b/src/test/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/serializer/ArchiveWriterTest.java new file mode 100644 index 00000000..b705a9b2 --- /dev/null +++ b/src/test/java/org/qubership/integration/platform/runtime/catalog/service/exportimport/serializer/ArchiveWriterTest.java @@ -0,0 +1,179 @@ +/* + * Copyright 2024-2025 NetCracker Technology Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.qubership.integration.platform.runtime.catalog.service.exportimport.serializer; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.qubership.integration.platform.runtime.catalog.model.system.exportimport.ExportableObject; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.qubership.integration.platform.runtime.catalog.service.exportimport.ExportImportConstants.ARCH_PARENT_DIR; + +@ExtendWith(MockitoExtension.class) +class ArchiveWriterTest { + + @Mock + ExportableObjectWriterVisitor exportableObjectWriterVisitor; + + @Captor + ArgumentCaptor entryPathCaptor; + + ArchiveWriter archiveWriter; + + @BeforeEach + void setUp() { + archiveWriter = new ArchiveWriter(exportableObjectWriterVisitor); + } + + @Test + @DisplayName("writeArchive with empty list returns valid empty zip bytes") + void writeArchiveEmptyListReturnsValidZip() throws IOException { + byte[] result = archiveWriter.writeArchive(List.of()); + + assertNotNull(result); + assertTrue(result.length > 0, "Result should contain zip header bytes"); + + // verify the bytes form a valid (empty) zip + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(result))) { + assertNull(zis.getNextEntry(), "Empty archive should have no entries"); + } + } + + @Test + @DisplayName("writeArchive calls accept on single object with correct entry path") + void writeArchiveSingleObjectCallsAcceptWithCorrectPath() throws IOException { + ExportableObject obj = mock(ExportableObject.class); + when(obj.getId()).thenReturn("obj-1"); + + archiveWriter.writeArchive(List.of(obj)); + + verify(obj).accept(eq(exportableObjectWriterVisitor), any(ZipOutputStream.class), entryPathCaptor.capture()); + + String expectedPath = ARCH_PARENT_DIR + File.separator + "obj-1" + File.separator; + assertThat(entryPathCaptor.getValue(), equalTo(expectedPath)); + } + + @Test + @DisplayName("writeArchive calls accept on each object in the list") + void writeArchiveMultipleObjectsCallsAcceptForEach() throws IOException { + ExportableObject obj1 = mock(ExportableObject.class); + ExportableObject obj2 = mock(ExportableObject.class); + ExportableObject obj3 = mock(ExportableObject.class); + when(obj1.getId()).thenReturn("id-1"); + when(obj2.getId()).thenReturn("id-2"); + when(obj3.getId()).thenReturn("id-3"); + + archiveWriter.writeArchive(List.of(obj1, obj2, obj3)); + + verify(obj1).accept(eq(exportableObjectWriterVisitor), any(ZipOutputStream.class), entryPathCaptor.capture()); + verify(obj2).accept(eq(exportableObjectWriterVisitor), any(ZipOutputStream.class), entryPathCaptor.capture()); + verify(obj3).accept(eq(exportableObjectWriterVisitor), any(ZipOutputStream.class), entryPathCaptor.capture()); + + List capturedPaths = entryPathCaptor.getAllValues(); + assertThat(capturedPaths, containsInAnyOrder( + ARCH_PARENT_DIR + File.separator + "id-1" + File.separator, + ARCH_PARENT_DIR + File.separator + "id-2" + File.separator, + ARCH_PARENT_DIR + File.separator + "id-3" + File.separator + )); + } + + @Test + @DisplayName("writeArchive wraps IOException in RuntimeException") + void writeArchiveWrapsIOExceptionInRuntimeException() throws IOException { + ExportableObject obj = mock(ExportableObject.class); + when(obj.getId()).thenReturn("obj-fail"); + doThrow(new IOException("disk error")) + .when(obj).accept(any(), any(), any()); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> archiveWriter.writeArchive(List.of(obj))); + + assertThat(ex.getMessage(), containsString("Failed to create archive")); + assertThat(ex.getCause(), instanceOf(IOException.class)); + } + + @Test + @DisplayName("writeArchive result contains a zip entry written by the visitor") + void writeArchiveResultContainsEntryWrittenByVisitor() throws IOException { + String entryName = "services/obj-1/system.yaml"; + String entryContent = "name: test"; + + ExportableObject obj = mock(ExportableObject.class); + when(obj.getId()).thenReturn("obj-1"); + doAnswer(invocation -> { + ZipOutputStream zos = invocation.getArgument(1); + zos.putNextEntry(new ZipEntry(entryName)); + zos.write(entryContent.getBytes()); + zos.closeEntry(); + return null; + }).when(obj).accept(any(), any(ZipOutputStream.class), any()); + + byte[] result = archiveWriter.writeArchive(List.of(obj)); + + List entryNames = new ArrayList<>(); + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(result))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + entryNames.add(entry.getName()); + } + } + + assertThat(entryNames, hasSize(1)); + assertThat(entryNames, hasItem(entryName)); + } + + @Test + @DisplayName("writeArchive passes the same ZipOutputStream instance to all accept calls") + void writeArchivePassesSameZipOutputStreamToAllObjects() throws IOException { + ExportableObject obj1 = mock(ExportableObject.class); + ExportableObject obj2 = mock(ExportableObject.class); + when(obj1.getId()).thenReturn("id-1"); + when(obj2.getId()).thenReturn("id-2"); + + ArgumentCaptor zipCaptor = ArgumentCaptor.forClass(ZipOutputStream.class); + + archiveWriter.writeArchive(List.of(obj1, obj2)); + + verify(obj1).accept(any(), zipCaptor.capture(), any()); + ZipOutputStream zip1 = zipCaptor.getValue(); + + verify(obj2).accept(any(), zipCaptor.capture(), any()); + ZipOutputStream zip2 = zipCaptor.getValue(); + + assertSame(zip1, zip2, "All objects must receive the same ZipOutputStream instance"); + } +}