diff --git a/src/main/java/org/gridsuite/explore/server/controller/ExploreController.java b/src/main/java/org/gridsuite/explore/server/controller/ExploreController.java index 3c4d47e6..c01f180e 100644 --- a/src/main/java/org/gridsuite/explore/server/controller/ExploreController.java +++ b/src/main/java/org/gridsuite/explore/server/controller/ExploreController.java @@ -19,6 +19,7 @@ import org.gridsuite.explore.server.dto.ReferencingElementInfos; import org.gridsuite.explore.server.services.DirectoryService; import org.gridsuite.explore.server.services.ExploreService; +import org.gridsuite.explore.server.services.StudyImportService; import org.gridsuite.explore.server.utils.ContingencyListType; import org.gridsuite.explore.server.utils.ParametersType; import org.springframework.http.HttpStatus; @@ -42,6 +43,7 @@ public class ExploreController { // /!\ This query parameter is used by the gateway to control access private static final String QUERY_PARAM_NAME = "name"; + private static final String QUERY_PARAM_STUDY_NAME = "studyName"; private static final String QUERY_PARAM_DESCRIPTION = "description"; private static final String QUERY_PARAM_PARENT_DIRECTORY_ID = "parentDirectoryUuid"; @@ -50,10 +52,12 @@ public class ExploreController { private final ExploreService exploreService; private final DirectoryService directoryService; + private final StudyImportService studyImportService; - public ExploreController(ExploreService exploreService, DirectoryService directoryService) { + public ExploreController(ExploreService exploreService, DirectoryService directoryService, StudyImportService studyImportService) { this.exploreService = exploreService; this.directoryService = directoryService; + this.studyImportService = studyImportService; } @PostMapping(value = "/explore/studies/{studyName}/cases/{caseUuid}") @@ -778,4 +782,18 @@ public ResponseEntity duplicateDynamicMapping(@PathVariable("id") UUID id, UUID newDynamicMappingUuid = exploreService.duplicateDynamicMapping(id, targetDirectoryId, userId); return ResponseEntity.ofNullable(newDynamicMappingUuid); } + + @PostMapping(value = "/explore/studies/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "Import a study from an archive") + @ApiResponses(value = {@ApiResponse(responseCode = "200", description = "Study import finished")}) + @PreAuthorize("@authorizationService.isAuthorized(#userId, #parentDirectoryUuid, null, T(org.gridsuite.explore.server.dto.PermissionType).WRITE)") + public ResponseEntity importStudy(@RequestParam(QUERY_PARAM_STUDY_NAME) String studyName, + @RequestPart("archiveFile") MultipartFile archiveFile, + @RequestParam(QUERY_PARAM_DESCRIPTION) String description, + @RequestParam(QUERY_PARAM_PARENT_DIRECTORY_ID) UUID parentDirectoryUuid, + @RequestHeader(QUERY_PARAM_USER_ID) String userId) { + exploreService.assertCanCreateCase(userId); + studyImportService.importStudy(archiveFile, studyName, description, userId, parentDirectoryUuid); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/org/gridsuite/explore/server/dto/CaseInfos.java b/src/main/java/org/gridsuite/explore/server/dto/CaseInfos.java new file mode 100644 index 00000000..8c183fbf --- /dev/null +++ b/src/main/java/org/gridsuite/explore/server/dto/CaseInfos.java @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.explore.server.dto; + +import java.util.UUID; + +/** + * @author Ghazwa Rehili + */ +public record CaseInfos( + UUID caseUuid, + UUID originalCaseUuid, + String caseName, + String caseFormat +) { +} diff --git a/src/main/java/org/gridsuite/explore/server/dto/NodeTreeExportInfos.java b/src/main/java/org/gridsuite/explore/server/dto/NodeTreeExportInfos.java new file mode 100644 index 00000000..6f03a998 --- /dev/null +++ b/src/main/java/org/gridsuite/explore/server/dto/NodeTreeExportInfos.java @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.explore.server.dto; + +import java.util.List; +import java.util.UUID; + +/** + * @author Ghazwa Rehili + */ +public record NodeTreeExportInfos( + String name, + String type, + UUID modificationGroupUuid, + String nodeType, + List children +) { +} diff --git a/src/main/java/org/gridsuite/explore/server/dto/RootNetworkExportInfos.java b/src/main/java/org/gridsuite/explore/server/dto/RootNetworkExportInfos.java new file mode 100644 index 00000000..07771019 --- /dev/null +++ b/src/main/java/org/gridsuite/explore/server/dto/RootNetworkExportInfos.java @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.explore.server.dto; + +import java.util.Map; + +/** + * @author Ghazwa Rehili + */ +public record RootNetworkExportInfos( + String name, + String tag, + Integer index, + CaseInfos caseInfos, + Map importParameters +) { +} diff --git a/src/main/java/org/gridsuite/explore/server/dto/TreeExportInfos.java b/src/main/java/org/gridsuite/explore/server/dto/TreeExportInfos.java new file mode 100644 index 00000000..68325cc1 --- /dev/null +++ b/src/main/java/org/gridsuite/explore/server/dto/TreeExportInfos.java @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.explore.server.dto; + +import java.util.List; +import java.util.UUID; + +/** + * @author Ghazwa Rehili + */ +public record TreeExportInfos( + UUID studyUuid, + List rootNetworks, + NodeTreeExportInfos nodeTree +) { +} diff --git a/src/main/java/org/gridsuite/explore/server/error/ExploreBusinessErrorCode.java b/src/main/java/org/gridsuite/explore/server/error/ExploreBusinessErrorCode.java index c1e55a48..89ef5247 100644 --- a/src/main/java/org/gridsuite/explore/server/error/ExploreBusinessErrorCode.java +++ b/src/main/java/org/gridsuite/explore/server/error/ExploreBusinessErrorCode.java @@ -14,7 +14,8 @@ * Business error codes emitted by the explore service. */ public enum ExploreBusinessErrorCode implements BusinessErrorCode { - EXPLORE_MAX_ELEMENTS_EXCEEDED("explore.maxElementsExceeded"); + EXPLORE_MAX_ELEMENTS_EXCEEDED("explore.maxElementsExceeded"), + IMPORT_STUDY_FAILED("explore.importStudyFailed"); private final String code; diff --git a/src/main/java/org/gridsuite/explore/server/error/ExploreException.java b/src/main/java/org/gridsuite/explore/server/error/ExploreException.java index f3275677..19944e27 100644 --- a/src/main/java/org/gridsuite/explore/server/error/ExploreException.java +++ b/src/main/java/org/gridsuite/explore/server/error/ExploreException.java @@ -32,6 +32,12 @@ public ExploreException(ExploreBusinessErrorCode errorCode, String message, Map< this.businessErrorValues = businessErrorValues != null ? Map.copyOf(businessErrorValues) : Map.of(); } + public ExploreException(ExploreBusinessErrorCode errorCode, String message, Throwable cause) { + super(Objects.requireNonNull(message, "message must not be null"), cause); + this.errorCode = Objects.requireNonNull(errorCode, "errorCode must not be null"); + this.businessErrorValues = Map.of(); + } + public static ExploreException of(ExploreBusinessErrorCode errorCode, String message, Object... args) { return new ExploreException(errorCode, args.length == 0 ? message : String.format(message, args)); } diff --git a/src/main/java/org/gridsuite/explore/server/error/ExploreExceptionHandler.java b/src/main/java/org/gridsuite/explore/server/error/ExploreExceptionHandler.java index a993b7c7..61f0106d 100644 --- a/src/main/java/org/gridsuite/explore/server/error/ExploreExceptionHandler.java +++ b/src/main/java/org/gridsuite/explore/server/error/ExploreExceptionHandler.java @@ -38,6 +38,7 @@ protected ExploreBusinessErrorCode getBusinessCode(ExploreException ex) { protected HttpStatus mapStatus(ExploreBusinessErrorCode errorCode) { return switch (errorCode) { case EXPLORE_MAX_ELEMENTS_EXCEEDED -> HttpStatus.FORBIDDEN; + case IMPORT_STUDY_FAILED -> HttpStatus.INTERNAL_SERVER_ERROR; }; } diff --git a/src/main/java/org/gridsuite/explore/server/services/CaseService.java b/src/main/java/org/gridsuite/explore/server/services/CaseService.java index 0f9f76ce..85fc666b 100644 --- a/src/main/java/org/gridsuite/explore/server/services/CaseService.java +++ b/src/main/java/org/gridsuite/explore/server/services/CaseService.java @@ -9,6 +9,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.http.*; import org.springframework.stereotype.Service; @@ -18,6 +19,7 @@ import org.springframework.web.multipart.MultipartFile; import org.springframework.web.util.UriComponentsBuilder; +import java.io.File; import java.util.List; import java.util.Map; import java.util.Objects; @@ -27,7 +29,7 @@ @Service public class CaseService implements IDirectoryElementsService { private static final String CASE_SERVER_API_VERSION = "v1"; - + private static final String CASES_URL = "cases"; private static final String DELIMITER = "/"; private final RestTemplate restTemplate; private String caseServerBaseUri; @@ -42,20 +44,12 @@ public void setBaseUri(String actionsServerBaseUri) { this.caseServerBaseUri = actionsServerBaseUri; } - UUID importCase(MultipartFile multipartFile) { - MultiValueMap body = new LinkedMultiValueMap<>(); - UUID caseUuid; - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.MULTIPART_FORM_DATA); + UUID importMultipartCase(MultipartFile multipartFile) { if (multipartFile != null) { Objects.requireNonNull(multipartFile.getOriginalFilename()); - body.add("file", multipartFile.getResource()); + return importCaseResource(multipartFile.getResource()); } - HttpEntity> request = new HttpEntity<>( - body, headers); - caseUuid = restTemplate.postForObject(caseServerBaseUri + "/" + CASE_SERVER_API_VERSION + "/cases", request, - UUID.class); - return caseUuid; + return importCaseResource(null); } public UUID importCaseWithoutDirectoryElementCreation(MultipartFile multipartFile, boolean withExpiration) { @@ -69,14 +63,14 @@ public UUID importCaseWithoutDirectoryElementCreation(MultipartFile multipartFil body.add("withExpiration", withExpiration); HttpEntity> request = new HttpEntity<>(body, headers); - String path = UriComponentsBuilder.fromPath(DELIMITER + CASE_SERVER_API_VERSION + "/cases") + String path = UriComponentsBuilder.fromPath(DELIMITER + CASE_SERVER_API_VERSION + DELIMITER + CASES_URL) .buildAndExpand() .toUriString(); return restTemplate.exchange(caseServerBaseUri + path, HttpMethod.POST, request, UUID.class).getBody(); } public ResponseEntity downloadCase(UUID caseUuid) { - String path = UriComponentsBuilder.fromPath(DELIMITER + CASE_SERVER_API_VERSION + "/cases/{caseUuid}") + String path = UriComponentsBuilder.fromPath(DELIMITER + CASE_SERVER_API_VERSION + DELIMITER + CASES_URL + DELIMITER + "{caseUuid}") .buildAndExpand(caseUuid) .toUriString(); @@ -84,7 +78,7 @@ public ResponseEntity downloadCase(UUID caseUuid) { } public Void deleteCase(UUID caseUuid) { - String path = UriComponentsBuilder.fromPath(DELIMITER + CASE_SERVER_API_VERSION + "/cases/{caseUuid}") + String path = UriComponentsBuilder.fromPath(DELIMITER + CASE_SERVER_API_VERSION + DELIMITER + CASES_URL + DELIMITER + "{caseUuid}") .buildAndExpand(caseUuid) .toUriString(); @@ -92,7 +86,7 @@ public Void deleteCase(UUID caseUuid) { } public String getBaseName(String caseName) { - String path = UriComponentsBuilder.fromPath(DELIMITER + CASE_SERVER_API_VERSION + "/cases/caseBaseName") + String path = UriComponentsBuilder.fromPath(DELIMITER + CASE_SERVER_API_VERSION + DELIMITER + CASES_URL + DELIMITER + "caseBaseName") .queryParam("caseName", caseName) .buildAndExpand() .toUriString(); @@ -100,8 +94,27 @@ public String getBaseName(String caseName) { return restTemplate.exchange(caseServerBaseUri + path, HttpMethod.GET, null, String.class).getBody(); } + public UUID importFileCase(File file) { + return importCaseResource(new FileSystemResource(file)); + } + + private UUID importCaseResource(Resource resource) { + MultiValueMap body = new LinkedMultiValueMap<>(); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + if (resource != null) { + body.add("file", resource); + } + HttpEntity> request = new HttpEntity<>(body, headers); + return restTemplate.postForObject( + caseServerBaseUri + "/" + CASE_SERVER_API_VERSION + DELIMITER + CASES_URL, + request, + UUID.class + ); + } + void persistCase(UUID caseUuid) { - String path = UriComponentsBuilder.fromPath(DELIMITER + CASE_SERVER_API_VERSION + "/cases/" + caseUuid + "/disableExpiration") + String path = UriComponentsBuilder.fromPath(DELIMITER + CASE_SERVER_API_VERSION + DELIMITER + CASES_URL + DELIMITER + caseUuid + "/disableExpiration") .buildAndExpand() .toUriString(); @@ -109,7 +122,7 @@ void persistCase(UUID caseUuid) { } UUID duplicateCase(UUID caseId) { - String path = UriComponentsBuilder.fromPath(DELIMITER + CASE_SERVER_API_VERSION + "/cases/{uuid}/duplicate") + String path = UriComponentsBuilder.fromPath(DELIMITER + CASE_SERVER_API_VERSION + DELIMITER + CASES_URL + DELIMITER + "{uuid}/duplicate") .buildAndExpand(caseId) .toUriString(); HttpHeaders headers = new HttpHeaders(); @@ -120,7 +133,7 @@ UUID duplicateCase(UUID caseId) { @Override public void delete(UUID id, String userId) { - String path = UriComponentsBuilder.fromPath(DELIMITER + CASE_SERVER_API_VERSION + "/cases/{id}") + String path = UriComponentsBuilder.fromPath(DELIMITER + CASE_SERVER_API_VERSION + DELIMITER + CASES_URL + DELIMITER + "{id}") .buildAndExpand(id) .toUriString(); HttpHeaders headers = new HttpHeaders(); @@ -132,7 +145,7 @@ public void delete(UUID id, String userId) { public List> getMetadata(List casesUuids) { var ids = casesUuids.stream().map(UUID::toString).collect(Collectors.joining(",")); String path = UriComponentsBuilder - .fromPath(DELIMITER + CASE_SERVER_API_VERSION + "/cases/metadata" + "?ids=" + ids) + .fromPath(DELIMITER + CASE_SERVER_API_VERSION + DELIMITER + CASES_URL + DELIMITER + "metadata" + "?ids=" + ids) .buildAndExpand() .toUriString(); return restTemplate.exchange(caseServerBaseUri + path, HttpMethod.GET, null, diff --git a/src/main/java/org/gridsuite/explore/server/services/ExploreService.java b/src/main/java/org/gridsuite/explore/server/services/ExploreService.java index 1566857f..a32af23f 100644 --- a/src/main/java/org/gridsuite/explore/server/services/ExploreService.java +++ b/src/main/java/org/gridsuite/explore/server/services/ExploreService.java @@ -145,7 +145,7 @@ public void duplicateStudy(UUID sourceStudyUuid, UUID targetDirectoryId, String } public void createCase(String caseName, MultipartFile caseFile, String description, String userId, UUID parentDirectoryUuid) { - UUID uuid = caseService.importCase(caseFile); + UUID uuid = caseService.importMultipartCase(caseFile); ElementAttributes elementAttributes = new ElementAttributes(uuid, caseName, CASE, userId, 0L, description); createDirectoryElementOrDeleteElement(elementAttributes, parentDirectoryUuid, userId, caseService::delete); } @@ -579,11 +579,11 @@ public UUID duplicateDynamicMapping(UUID sourceDynamicMappingUuid, UUID targetDi return newDynamicMappingUuid; } - private void createDirectoryElementOrDeleteElement(ElementAttributes elementAttributes, UUID parentDirectoryUuid, String userId, BiConsumer rollback) { + void createDirectoryElementOrDeleteElement(ElementAttributes elementAttributes, UUID parentDirectoryUuid, String userId, BiConsumer rollback) { executeWithRollback(() -> directoryService.createElement(elementAttributes, parentDirectoryUuid, userId), elementAttributes.getElementUuid(), userId, rollback); } - private void createDirectoryElementWithNewNameOrDeleteElement(ElementAttributes elementAttributes, UUID parentDirectoryUuid, String userId, BiConsumer rollback) { + void createDirectoryElementWithNewNameOrDeleteElement(ElementAttributes elementAttributes, UUID parentDirectoryUuid, String userId, BiConsumer rollback) { executeWithRollback(() -> directoryService.createElementWithNewName(elementAttributes, parentDirectoryUuid, userId, true), elementAttributes.getElementUuid(), userId, rollback); } diff --git a/src/main/java/org/gridsuite/explore/server/services/StudyImportService.java b/src/main/java/org/gridsuite/explore/server/services/StudyImportService.java new file mode 100644 index 00000000..6a84820d --- /dev/null +++ b/src/main/java/org/gridsuite/explore/server/services/StudyImportService.java @@ -0,0 +1,203 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.explore.server.services; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.powsybl.ws.commons.SecuredZipInputStream; +import org.gridsuite.explore.server.dto.*; +import org.gridsuite.explore.server.error.ExploreException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.*; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; + +import static org.gridsuite.explore.server.error.ExploreBusinessErrorCode.IMPORT_STUDY_FAILED; +import static org.gridsuite.explore.server.services.ExploreService.CASE; +import static org.gridsuite.explore.server.services.ExploreService.DIRECTORY; +import static org.gridsuite.explore.server.services.ExploreService.STUDY; + +/** + * @author Ghazwa Rehili + */ +@Service +public class StudyImportService { + + private static final Logger LOGGER = LoggerFactory.getLogger(StudyImportService.class); + public static final int MAX_UNCOMPRESSED_ARCHIVE_SIZE = 2000000000; + public static final int MAX_ARCHIVE_ENTRIES = 1000; + private final CaseService caseService; + private final StudyService studyService; + private final ObjectMapper objectMapper; + private final ExploreService exploreService; + private final DirectoryService directoryService; + + public StudyImportService(CaseService caseService, StudyService studyService, ObjectMapper objectMapper, ExploreService exploreService, DirectoryService directoryService) { + this.caseService = caseService; + this.studyService = studyService; + this.objectMapper = objectMapper; + this.exploreService = exploreService; + this.directoryService = directoryService; + } + + /** + * Import a study from an archive synchronously + * @param archiveFile the zip archive file + * @param studyName the name for the new study + * @param description the description for the new study + * @param userId the user ID + * @param parentDirectoryUuid the parent directory UUID + */ + public void importStudy(MultipartFile archiveFile, String studyName, String description, String userId, UUID parentDirectoryUuid) { + try { + FileAttribute> attr = PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")); + Path tempDir = Files.createTempDirectory("study-import-", attr); + try { + importStudyFromArchive(archiveFile, studyName, description, userId, parentDirectoryUuid, tempDir); + } finally { + deleteDirectory(tempDir); + } + } catch (ExploreException e) { + throw new ExploreException(e.getBusinessErrorCode(), "Error importing study archive '" + studyName + "': " + e.getMessage(), e); + } catch (Exception e) { + throw new ExploreException(IMPORT_STUDY_FAILED, "Error while importing study: " + e.getMessage(), e); + } + } + + private void importStudyFromArchive(MultipartFile archiveFile, String studyName, String description, String userId, + UUID parentDirectoryUuid, Path tempDir) throws IOException { + extractArchive(archiveFile.getInputStream(), tempDir); + Path studyJsonPath = tempDir.resolve("tree.json"); + if (!Files.exists(studyJsonPath)) { + throw new ExploreException(IMPORT_STUDY_FAILED, "tree.json not found in archive"); + } + TreeExportInfos treeExportInfos = objectMapper.readValue(studyJsonPath.toFile(), TreeExportInfos.class); + if (treeExportInfos == null || treeExportInfos.rootNetworks() == null || treeExportInfos.rootNetworks().isEmpty()) { + throw new ExploreException(IMPORT_STUDY_FAILED, "No root networks found in archive"); + } + Map oldCaseUuidToNewCaseUuid = new HashMap<>(); + ElementAttributes elementAttributes = new ElementAttributes(UUID.randomUUID(), studyName, DIRECTORY, userId, 0L, null); + ElementAttributes newElementAttributes = directoryService.createElement(elementAttributes, parentDirectoryUuid, userId); + UUID importDirectoryUuid = newElementAttributes.getElementUuid(); + try { + Path casesDir = tempDir.resolve("cases"); + for (var rootNetwork : treeExportInfos.rootNetworks()) { + importCaseForRootNetwork(rootNetwork, casesDir, oldCaseUuidToNewCaseUuid, description, userId, importDirectoryUuid); + } + checkAllCasesWereImported(treeExportInfos, oldCaseUuidToNewCaseUuid); + UUID createdStudyUuid = UUID.randomUUID(); + TreeExportInfos updatedExportInfos = updateCaseUuidsAndStudyUuidInExportInfos(treeExportInfos, oldCaseUuidToNewCaseUuid, createdStudyUuid); + createStudyFromImport(createdStudyUuid, studyName, userId, description, importDirectoryUuid, updatedExportInfos); + } catch (Exception exception) { + directoryService.deleteElement(importDirectoryUuid, userId); + if (exception instanceof ExploreException exploreException) { + throw new ExploreException(exploreException.getBusinessErrorCode(), "Failed to import study: " + exploreException.getMessage(), exploreException); + } + throw new ExploreException(IMPORT_STUDY_FAILED, "Failed to import study: " + exception.getMessage(), exception); + } + } + + private void createStudyFromImport(UUID createdStudyUuid, String studyName, String userId, String description, + UUID parentDirectoryUuid, TreeExportInfos updatedExportInfos) { + ElementAttributes elementAttributes = new ElementAttributes(createdStudyUuid, studyName, STUDY, userId, 0L, description, DirectoryElementStatus.CREATING); + exploreService.createDirectoryElementOrDeleteElement(elementAttributes, parentDirectoryUuid, userId, studyService::delete); + studyService.importStudy(userId, updatedExportInfos); + } + + private void importCaseForRootNetwork(RootNetworkExportInfos rootNetwork, Path casesDir, Map oldCaseUuidToNewCaseUuid, String description, String userId, UUID parentDirectoryUuid) { + UUID oldCaseUuid = rootNetwork.caseInfos().caseUuid(); + String caseName = rootNetwork.caseInfos().caseName(); + Path caseDir = casesDir.resolve(oldCaseUuid.toString()); + Path caseFile = caseDir.resolve(caseName); + try { + UUID newCaseUuid = caseService.importFileCase(caseFile.toFile()); + ElementAttributes elementAttributes = new ElementAttributes(newCaseUuid, caseName, CASE, userId, 0L, description); + oldCaseUuidToNewCaseUuid.put(oldCaseUuid, newCaseUuid); + exploreService.createDirectoryElementWithNewNameOrDeleteElement(elementAttributes, parentDirectoryUuid, userId, caseService::delete); + } catch (ExploreException e) { + throw e; + } catch (Exception e) { + LOGGER.error("Failed to import case file {}: {}", caseFile, e.getMessage(), e); + throw new ExploreException(IMPORT_STUDY_FAILED, "Failed to import case file: " + caseName + ": " + e.getMessage()); + } + } + + private void checkAllCasesWereImported(TreeExportInfos treeExportInfos, Map oldCaseUuidToNewCaseUuid) { + for (var rootNetwork : treeExportInfos.rootNetworks()) { + UUID oldCaseUuid = rootNetwork.caseInfos().caseUuid(); + if (!oldCaseUuidToNewCaseUuid.containsKey(oldCaseUuid)) { + throw new ExploreException(IMPORT_STUDY_FAILED, "Failed to import case: " + rootNetwork.caseInfos().caseName()); + } + } + } + + /** + * Extract zip archive to directory + */ + private void extractArchive(InputStream inputStream, Path destDir) throws IOException { + try (SecuredZipInputStream zipIn = new SecuredZipInputStream(inputStream, MAX_ARCHIVE_ENTRIES, MAX_UNCOMPRESSED_ARCHIVE_SIZE)) { + ZipEntry entry; + while ((entry = zipIn.getNextEntry()) != null) { + Path outputPath = destDir.resolve(entry.getName()).normalize(); + if (!outputPath.startsWith(destDir)) { + throw new IOException("Invalid zip entry: " + entry.getName()); + } + if (entry.isDirectory()) { + Files.createDirectories(outputPath); + } else { + if (outputPath.getParent() != null) { + Files.createDirectories(outputPath.getParent()); + } + Files.copy(zipIn, outputPath, StandardCopyOption.REPLACE_EXISTING); + } + zipIn.closeEntry(); + } + } + } + + /** + * Update case UUIDs in StudyExportInfos with new imported case UUIDs + */ + private TreeExportInfos updateCaseUuidsAndStudyUuidInExportInfos(TreeExportInfos original, Map oldCaseUuidToNewCaseUuid, UUID createdStudyUuid) { + List updatedRootNetworks = original.rootNetworks().stream().map(rootNetwork -> { + UUID oldCaseUuid = rootNetwork.caseInfos().caseUuid(); + UUID newCaseUuid = oldCaseUuidToNewCaseUuid.get(oldCaseUuid); + CaseInfos updatedCaseInfo = new CaseInfos(newCaseUuid, rootNetwork.caseInfos().originalCaseUuid(), + rootNetwork.caseInfos().caseName(), rootNetwork.caseInfos().caseFormat()); + return new RootNetworkExportInfos(rootNetwork.name(), rootNetwork.tag(), rootNetwork.index(), updatedCaseInfo, rootNetwork.importParameters()); + }).toList(); + return new TreeExportInfos(createdStudyUuid, updatedRootNetworks, original.nodeTree()); + } + + /** + * Recursively delete a directory + */ + private void deleteDirectory(Path directory) throws IOException { + if (Files.exists(directory)) { + try (Stream walk = Files.walk(directory)) { + walk.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.delete(path); + } catch (IOException e) { + LOGGER.warn("Failed to delete {}", path, e); + } + }); + } + } + } +} diff --git a/src/main/java/org/gridsuite/explore/server/services/StudyService.java b/src/main/java/org/gridsuite/explore/server/services/StudyService.java index 9af8edef..ff012963 100644 --- a/src/main/java/org/gridsuite/explore/server/services/StudyService.java +++ b/src/main/java/org/gridsuite/explore/server/services/StudyService.java @@ -8,13 +8,16 @@ import org.apache.commons.lang3.StringUtils; import org.gridsuite.explore.server.dto.NodeInfos; +import org.gridsuite.explore.server.dto.TreeExportInfos; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; -import java.util.*; +import java.util.List; +import java.util.Map; +import java.util.UUID; import java.util.stream.Collectors; /** @@ -115,4 +118,14 @@ public ResponseEntity notifyStudyUpdate(UUID studyUuid, String userId) { headers.set(HEADER_USER_ID, userId); return restTemplate.exchange(studyServerBaseUri + path, HttpMethod.POST, new HttpEntity<>(headers), Void.class); } + + public void importStudy(String userId, TreeExportInfos treeExportInfos) { + String path = UriComponentsBuilder.fromPath(DELIMITER + STUDY_SERVER_API_VERSION + "/studies/import").toUriString(); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.add(HEADER_USER_ID, userId); + + HttpEntity request = new HttpEntity<>(treeExportInfos, headers); + restTemplate.exchange(studyServerBaseUri + path, HttpMethod.POST, request, Void.class); + } } diff --git a/src/test/java/org/gridsuite/explore/server/StudyImportExportTest.java b/src/test/java/org/gridsuite/explore/server/StudyImportExportTest.java new file mode 100644 index 00000000..5024f9cf --- /dev/null +++ b/src/test/java/org/gridsuite/explore/server/StudyImportExportTest.java @@ -0,0 +1,437 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.explore.server; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.tomakehurst.wiremock.WireMockServer; +import org.gridsuite.explore.server.dto.*; +import org.gridsuite.explore.server.services.CaseService; +import org.gridsuite.explore.server.services.DirectoryService; +import org.gridsuite.explore.server.services.StudyService; +import org.gridsuite.explore.server.services.UserAdminService; +import org.gridsuite.explore.server.utils.WireMockUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.*; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Ghazwa Rehili + */ +@AutoConfigureMockMvc +@SpringBootTest +class StudyImportExportTest { + + private static final UUID PARENT_DIRECTORY_UUID = UUID.randomUUID(); + private static final UUID CASE_UUID = UUID.randomUUID(); + private static final UUID STUDY_UUID = UUID.randomUUID(); + private static final String USER_ID = "testUser"; + private static final String STUDY_NAME = "Test Study"; + private static final String DESCRIPTION = "Test Description"; + + @Autowired + private MockMvc mockMvc; + + private WireMockServer wireMockServer; + + protected WireMockUtils wireMockUtils; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private StudyService studyService; + + @Autowired + private CaseService caseService; + + @Autowired + private DirectoryService directoryService; + + @Autowired + private UserAdminService userAdminService; + + @BeforeEach + void setUp() throws JsonProcessingException { + wireMockServer = new WireMockServer(wireMockConfig().dynamicPort()); + wireMockUtils = new WireMockUtils(wireMockServer); + wireMockServer.start(); + studyService.setStudyServerBaseUri(wireMockServer.baseUrl()); + caseService.setBaseUri(wireMockServer.baseUrl()); + directoryService.setDirectoryServerBaseUri(wireMockServer.baseUrl()); + userAdminService.setUserAdminServerBaseUri(wireMockServer.baseUrl()); + + // Stub case-server + wireMockServer.stubFor(post(urlPathMatching("/v1/cases")) + .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(objectMapper.writeValueAsString(CASE_UUID)))); + wireMockServer.stubFor(get(urlPathMatching("/v1/users/.*/cases/count")) + .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("0"))); + // Stub study-server: import + wireMockServer.stubFor(post(urlPathMatching("/v1/studies/import")) + .willReturn(aResponse().withStatus(200))); + // Stub directory-server + wireMockServer.stubFor(get(urlPathMatching("/v1/elements/authorized")) + .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("true"))); + wireMockServer.stubFor(post(urlPathMatching("/v1/directories/.*/elements")) + .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString(new ElementAttributes(UUID.randomUUID(), STUDY_NAME, "DIRECTORY", USER_ID, 0L, null))))); + wireMockServer.stubFor(get(urlPathMatching("/v1/cases-alert-threshold")) + .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody("10"))); + // Stub user-admin-server max quota + wireMockServer.stubFor(get(urlPathMatching("/v1/users/.*/quota/max")) + .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString(Map.of(QuotaType.CASES, 10))))); + } + + @AfterEach + void tearDown() { + if (wireMockServer != null) { + wireMockServer.stop(); + } + } + + @Test + void testImportStudyArchive() throws Exception { + // Create a valid archive + byte[] archiveContent = createValidStudyArchive(); + MockMultipartFile archiveFile = new MockMultipartFile( + "archiveFile", + "study-export.zip", + "application/zip", + archiveContent + ); + + // Import the study + MvcResult result = mockMvc.perform(multipart("/v1/explore/studies/import") + .file(archiveFile) + .param("studyName", STUDY_NAME) + .param("description", DESCRIPTION) + .param("parentDirectoryUuid", PARENT_DIRECTORY_UUID.toString()) + .header("userId", USER_ID)) + .andExpect(status().isOk()) + .andReturn(); + + // Verify the import was initiated + assertNotNull(result); + } + + @Test + void testImportStudyArchiveMissingTreeJson() throws Exception { + // Create an archive without tree.json + byte[] archiveContent = createArchiveWithoutTreeJson(); + MockMultipartFile archiveFile = new MockMultipartFile( + "archiveFile", + "invalid-study.zip", + "application/zip", + archiveContent + ); + + // Attempt to import - should fail + mockMvc.perform(multipart("/v1/explore/studies/import") + .file(archiveFile) + .param("studyName", STUDY_NAME) + .param("description", DESCRIPTION) + .param("parentDirectoryUuid", PARENT_DIRECTORY_UUID.toString()) + .header("userId", USER_ID)) + .andExpect(status().is5xxServerError()); + } + + @Test + void testImportStudyArchiveInvalidZipFile() throws Exception { + // Create an invalid zip file + byte[] invalidContent = "This is not a valid zip file".getBytes(); + MockMultipartFile archiveFile = new MockMultipartFile( + "archiveFile", + "invalid.zio", + "application/zip", + invalidContent + ); + + // Attempt to import - should fail + mockMvc.perform(multipart("/v1/explore/studies/import") + .file(archiveFile) + .param("studyName", STUDY_NAME) + .param("description", DESCRIPTION) + .param("parentDirectoryUuid", PARENT_DIRECTORY_UUID.toString()) + .header("userId", USER_ID)) + .andExpect(status().is5xxServerError()); + } + + @Test + void testImportStudyArchiveMultipleRootNetworks() throws Exception { + // Create an archive with multiple root networks + byte[] archiveContent = createArchiveWithMultipleRootNetworks(); + MockMultipartFile archiveFile = new MockMultipartFile( + "archiveFile", + "multi-root-study.zip", + "application/zip", + archiveContent + ); + + // Import the study + mockMvc.perform(multipart("/v1/explore/studies/import") + .file(archiveFile) + .param("studyName", STUDY_NAME) + .param("description", DESCRIPTION) + .param("parentDirectoryUuid", PARENT_DIRECTORY_UUID.toString()) + .header("userId", USER_ID)) + .andExpect(status().isOk()); + } + + @Test + void testImportStudyArchiveEmptyRootNetworks() throws Exception { + // Create an archive with no root networks + byte[] archiveContent = createArchiveWithEmptyRootNetworks(); + MockMultipartFile archiveFile = new MockMultipartFile( + "archiveFile", + "empty-roots.zio", + "application/zip", + archiveContent + ); + + // Attempt to import - should fail + mockMvc.perform(multipart("/v1/explore/studies/import") + .file(archiveFile) + .param("studyName", STUDY_NAME) + .param("description", DESCRIPTION) + .param("parentDirectoryUuid", PARENT_DIRECTORY_UUID.toString()) + .header("userId", USER_ID)) + .andExpect(status().is5xxServerError()); + } + + @Test + void testImportStudyArchiveMissingCaseFile() throws Exception { + // Create an archive where the case file referenced in tree.json doesn't exist + byte[] archiveContent = createArchiveWithMissingCaseFile(); + MockMultipartFile archiveFile = new MockMultipartFile( + "archiveFile", + "missing-case.zip", + "application/zip", + archiveContent + ); + + // Attempt to import - should fail + mockMvc.perform(multipart("/v1/explore/studies/import") + .file(archiveFile) + .param("studyName", STUDY_NAME) + .param("description", DESCRIPTION) + .param("parentDirectoryUuid", PARENT_DIRECTORY_UUID.toString()) + .header("userId", USER_ID)) + .andExpect(status().is5xxServerError()); + } + + @Test + void testImportStudyFailure() throws Exception { + byte[] archiveContent = createArchiveWithOneMissingCaseFileAmongTwoRoots(); + MockMultipartFile archiveFile = new MockMultipartFile( + "archiveFile", + "partial-study.zip", + "application/zip", + archiveContent + ); + + UUID importDirectoryUuid = UUID.randomUUID(); + wireMockServer.stubFor(post(urlPathEqualTo("/v1/directories/" + PARENT_DIRECTORY_UUID + "/elements")) + .atPriority(1) + .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString(new ElementAttributes(importDirectoryUuid, STUDY_NAME, "DIRECTORY", USER_ID, 0L, null))))); + wireMockServer.stubFor(post(urlPathEqualTo("/v1/directories/" + importDirectoryUuid + "/elements")) + .atPriority(1) + .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString(new ElementAttributes(CASE_UUID, "case-valid.xiidm", "CASE", USER_ID, 0L, DESCRIPTION))))); + + wireMockServer.stubFor(get(urlPathEqualTo("/v1/elements/" + importDirectoryUuid)) + .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString(new ElementAttributes(importDirectoryUuid, STUDY_NAME, "DIRECTORY", USER_ID, 0L, null))))); + wireMockServer.stubFor(get(urlPathEqualTo("/v1/directories/" + importDirectoryUuid + "/elements")) + .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString(List.of(new ElementAttributes(CASE_UUID, "case-valid.xiidm", "CASE", USER_ID, 0L, DESCRIPTION)))))); + wireMockServer.stubFor(get(urlPathEqualTo("/v1/elements/" + CASE_UUID)) + .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString(new ElementAttributes(CASE_UUID, "case-valid.xiidm", "CASE", USER_ID, 0L, DESCRIPTION))))); + wireMockServer.stubFor(delete(urlPathEqualTo("/v1/cases/" + CASE_UUID)) + .willReturn(aResponse().withStatus(200))); + + mockMvc.perform(multipart("/v1/explore/studies/import") + .file(archiveFile) + .param("studyName", STUDY_NAME) + .param("description", DESCRIPTION) + .param("parentDirectoryUuid", PARENT_DIRECTORY_UUID.toString()) + .header("userId", USER_ID)) + .andExpect(status().is5xxServerError()); + + wireMockServer.verify(deleteRequestedFor(urlPathEqualTo("/v1/cases/" + CASE_UUID))); + } + + private byte[] createValidStudyArchive() throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + // Add tree.json + TreeExportInfos exportInfos = createStudyExportInfos(); + addJsonEntry(zos, exportInfos); + + // Add case file + String caseName = "testCase.xiidm"; + addFileEntry(zos, "cases/" + CASE_UUID + "/" + caseName, "".getBytes()); + } + return baos.toByteArray(); + } + + private byte[] createArchiveWithoutTreeJson() throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + // Add only case file, no tree.json + addFileEntry(zos, "cases/" + CASE_UUID + "/test.xiidm", "".getBytes()); + } + return baos.toByteArray(); + } + + private byte[] createArchiveWithMultipleRootNetworks() throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + // Create export info with 3 root networks + TreeExportInfos exportInfos = createStudyExportInfosWithMultipleRoots(); + addJsonEntry(zos, exportInfos); + + // Add case files for each root network + for (RootNetworkExportInfos rootNetwork : exportInfos.rootNetworks()) { + UUID caseUuid = rootNetwork.caseInfos().caseUuid(); + String caseName = rootNetwork.caseInfos().caseName(); + addFileEntry(zos, "cases/" + caseUuid + "/" + caseName, "".getBytes()); + } + } + return baos.toByteArray(); + } + + private byte[] createArchiveWithEmptyRootNetworks() throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + // Create export info with empty root networks list + TreeExportInfos exportInfos = new TreeExportInfos( + STUDY_UUID, + Collections.emptyList(), + createNodeTree() + ); + addJsonEntry(zos, exportInfos); + } + return baos.toByteArray(); + } + + private byte[] createArchiveWithMissingCaseFile() throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + // Add tree.json with case reference + TreeExportInfos exportInfos = createStudyExportInfos(); + addJsonEntry(zos, exportInfos); + // But don't add the actual case file + } + return baos.toByteArray(); + } + + private byte[] createArchiveWithOneMissingCaseFileAmongTwoRoots() throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + UUID validCaseUuid = UUID.randomUUID(); + CaseInfos validCaseInfo = new CaseInfos(validCaseUuid, UUID.randomUUID(), "case-valid.xiidm", "XIIDM"); + RootNetworkExportInfos validRootNetwork = new RootNetworkExportInfos("Network 1", "1", 0, validCaseInfo, Collections.emptyMap()); + + UUID missingCaseUuid = UUID.randomUUID(); + CaseInfos missingCaseInfo = new CaseInfos(missingCaseUuid, UUID.randomUUID(), "case-missing.xiidm", "XIIDM"); + RootNetworkExportInfos missingRootNetwork = new RootNetworkExportInfos("Network 2", "2", 1, missingCaseInfo, Collections.emptyMap()); + + TreeExportInfos exportInfos = new TreeExportInfos(STUDY_UUID, List.of(validRootNetwork, missingRootNetwork), createNodeTree()); + addJsonEntry(zos, exportInfos); + + // only the first root network's case file is present in the archive + addFileEntry(zos, "cases/" + validCaseUuid + "/case-valid.xiidm", "".getBytes()); + } + return baos.toByteArray(); + } + + private void addJsonEntry(ZipOutputStream zos, Object content) throws IOException { + ZipEntry entry = new ZipEntry("tree.json"); + zos.putNextEntry(entry); + zos.write(objectMapper.writeValueAsBytes(content)); + zos.closeEntry(); + } + + private void addFileEntry(ZipOutputStream zos, String entryName, byte[] content) throws IOException { + ZipEntry entry = new ZipEntry(entryName); + zos.putNextEntry(entry); + zos.write(content); + zos.closeEntry(); + } + + private TreeExportInfos createStudyExportInfos() { + CaseInfos caseInfo = new CaseInfos(CASE_UUID, UUID.randomUUID(), "testCase.xiidm", "XIIDM"); + RootNetworkExportInfos rootNetwork = new RootNetworkExportInfos( + "Network 1", + "1", + 0, + caseInfo, + Collections.emptyMap() + ); + return new TreeExportInfos( + STUDY_UUID, + Collections.singletonList(rootNetwork), + createNodeTree() + ); + } + + private TreeExportInfos createStudyExportInfosWithMultipleRoots() { + List rootNetworks = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + UUID caseUuid = UUID.randomUUID(); + CaseInfos caseInfo = new CaseInfos(caseUuid, UUID.randomUUID(), "case" + i + ".xiidm", "XIIDM"); + RootNetworkExportInfos rootNetwork = new RootNetworkExportInfos( + "Network " + (i + 1), + String.valueOf(i + 1), + 0, + caseInfo, + Collections.emptyMap() + ); + rootNetworks.add(rootNetwork); + } + return new TreeExportInfos(STUDY_UUID, rootNetworks, createNodeTree()); + } + + private NodeTreeExportInfos createNodeTree() { + List children = new ArrayList<>(); + children.add(new NodeTreeExportInfos( + "Node 1", + "NETWORK_MODIFICATION", + UUID.randomUUID(), + "CONSTRUCTION", + Collections.emptyList() + )); + return new NodeTreeExportInfos( + "Root", + "ROOT", + null, + "CONSTRUCTION", + children + ); + } +}