Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* 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.modification.server;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.gridsuite.modification.dto.ModificationInfos;
import org.gridsuite.modification.server.dto.ModificationApplicationContext;
import org.gridsuite.modification.server.dto.NetworkModificationsResult;
import org.gridsuite.modification.server.service.NetworkModificationService;
import org.springframework.data.util.Pair;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;

/**
* @author Mathieu Deharbe <mathieu.deharbe at rte-france.com>
*/
@RestController
@RequestMapping(value = "/" + NetworkModificationApi.API_VERSION + "/network-composite-modifications")
@Tag(name = "network-modification-server - Composite modifications")
public class CompositeController {

public enum CompositeModificationAction {
SPLIT, // the network modifications contained into the composite modifications are extracted and inserted one by one
INSERT // the composite modifications are fully inserted as composite modifications
}

private final NetworkModificationService networkModificationService;

public CompositeController(NetworkModificationService networkModificationService) {
this.networkModificationService = networkModificationService;
}

@PutMapping(value = "/groups/{groupUuid}", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "Insert a list of composite network modifications passed in body at the end of a group")
@ApiResponse(responseCode = "200", description = "The composite modification list has been added to the group.")
public CompletableFuture<ResponseEntity<NetworkModificationsResult>> insertCompositeModifications(
@Parameter(description = "updated group UUID, where modifications are inserted") @PathVariable("groupUuid") UUID targetGroupUuid,
@Parameter(description = "Insertion method", required = true) @RequestParam(value = "action") CompositeModificationAction action,
@RequestBody Pair<List<Pair<UUID, String>>, List<ModificationApplicationContext>> modificationContextInfos) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the need of a Pair inbetween List<Pair<UUID, String>> and List I'm not sure I get the logic here, there can be multiple instances of List<Pair<UUID, String> as input ?

Copy link
Copy Markdown
Contributor Author

@Mathieu-Deharbe Mathieu-Deharbe Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure that I understand your question.
Pair<List<Pair<UUID, String>>, List<ModificationApplicationContext>>
mans :

  • one List<Pair<UUID, String> : yes there might be several composite modifications inserted simultaneously.
  • one List<ModificationApplicationContext> : I didn't look into it much but there are several ModificationApplicationContext, one for each root network in the targetted study.

But no there can't be multiple instances of List<Pair<UUID, String> as input.

I don't really know why this data is sent as a pair though. I just kept the previous system from handleNetworkModifications. It could be separated, but they are in the body so this is probably the reason.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah i just checked it follows what was done in handleNetworkModifications, that said it is getting really confusing the way those data structure get imbricated

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed. Maybe in the end we will have to use a dto and copy it to study-server...

return switch (action) {
case SPLIT ->
networkModificationService.splitCompositeModifications(targetGroupUuid, modificationContextInfos)
.thenApply(ResponseEntity.ok()::body);
case INSERT ->
networkModificationService.insertCompositeModifications(
targetGroupUuid,
modificationContextInfos
).thenApply(ResponseEntity.ok()::body);
};
}

@PostMapping(value = "", consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "Create a network composite modification")
@ApiResponses(value = {@ApiResponse(responseCode = "200", description = "The composite modification has been created")})
public ResponseEntity<UUID> createNetworkCompositeModification(@RequestBody List<UUID> modificationUuids) {
return ResponseEntity.ok().body(networkModificationService.createNetworkCompositeModification(modificationUuids));
}

@GetMapping(value = "/network-modifications", produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "Get the list of all the network modifications inside a list of composite modifications")
@ApiResponse(responseCode = "200", description = "List of modifications inside the composite modifications")
public ResponseEntity<List<ModificationInfos>> getNetworkModificationsFromComposite(@Parameter(description = "Composite modifications uuids list") @RequestParam("uuids") List<UUID> compositeModificationUuids,
@Parameter(description = "Only metadata") @RequestParam(name = "onlyMetadata", required = false, defaultValue = "true") Boolean onlyMetadata) {
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(networkModificationService.getNetworkModificationsFromComposite(compositeModificationUuids, onlyMetadata)
);
}

@PostMapping(value = "/duplication", consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "Duplicate some composite modifications")
@ApiResponses(value = {@ApiResponse(responseCode = "200", description = "The duplicated modifications uuids mapped with their source uuid")})
public ResponseEntity<Map<UUID, UUID>> duplicateCompositeModifications(@Parameter(description = "source modifications uuids list to duplicate") @RequestBody List<UUID> sourceModificationUuids) {
return ResponseEntity.ok().body(networkModificationService.duplicateCompositeModifications(sourceModificationUuids));
}

@PutMapping(value = "/{uuid}", consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "Update a network composite modification")
@ApiResponses(value = {@ApiResponse(responseCode = "200", description = "The composite modification has been updated")})
public ResponseEntity<Void> updateNetworkCompositeModification(@PathVariable("uuid") UUID compositeModificationUuid,
@RequestBody List<UUID> modificationUuids) {
networkModificationService.updateCompositeModification(compositeModificationUuid, modificationUuids);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.gridsuite.modification.dto.ModificationInfos;
import org.gridsuite.modification.dto.ModificationsToCopyInfos;
import org.gridsuite.modification.server.dto.*;
import org.gridsuite.modification.server.dto.catalog.LineTypeInfos;
import org.gridsuite.modification.server.service.LineTypesCatalogService;
Expand All @@ -38,8 +37,6 @@ public class NetworkModificationController {
private enum GroupModificationAction {
MOVE,
COPY,
SPLIT_COMPOSITE, // the network modifications contained into the composite modifications are extracted and inserted one by one
INSERT_COMPOSITE // the composite modifications are fully inserted as composite modifications
}

private final NetworkModificationService networkModificationService;
Expand Down Expand Up @@ -97,24 +94,20 @@ public ResponseEntity<Map<UUID, UUID>> duplicateGroup(@RequestParam("groupUuid")
}

@PutMapping(value = "/groups/{groupUuid}", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "For a list of network modifications passed in body, Move them before another one or at the end of the list, or Duplicate them at the end of the list, or Insert them (composite) at the end of the list")
@Operation(summary = "For a list of network modifications passed in body, Move them before another one or at the end of the list, or Duplicate them at the end of the list")
@ApiResponse(responseCode = "200", description = "The modification list of the group has been updated.")
public CompletableFuture<ResponseEntity<NetworkModificationsResult>> handleNetworkModifications(
@Parameter(description = "updated group UUID, where modifications are pasted") @PathVariable("groupUuid") UUID targetGroupUuid,
@Parameter(description = "kind of modification", required = true) @RequestParam(value = "action") GroupModificationAction action,
@Parameter(description = "the modification Uuid to move before (MOVE option, empty means moving at the end)") @RequestParam(value = "before", required = false) UUID beforeModificationUuid,
@Parameter(description = "origin group UUID, where modifications are copied or cut") @RequestParam(value = "originGroupUuid", required = false) UUID originGroupUuid,
@Parameter(description = "modifications can be applied (default is true)") @RequestParam(value = "build", required = false, defaultValue = "true") Boolean canApply,
@RequestBody Pair<List<ModificationsToCopyInfos>, List<ModificationApplicationContext>> modificationContextInfos) {
List<UUID> modificationsUuids = modificationContextInfos.getFirst().stream().map(ModificationsToCopyInfos::getUuid).toList();
@RequestBody Pair<List<UUID>, List<ModificationApplicationContext>> modificationContextInfos) {
Comment on lines +97 to +105
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This narrows an existing v1 endpoint in a breaking way.

The /v1/groups/{groupUuid} contract now only binds COPY|MOVE, and the request body is now a bare UUID list instead of the previous object payload shape. Existing clients will start getting 400s until they are redeployed against the new composite route and payload. Please keep a deprecated compatibility layer for one release, or version this API change explicitly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/gridsuite/modification/server/NetworkModificationController.java`
around lines 97 - 105, The new handleNetworkModifications signature narrows
supported actions and changes the request body shape, breaking existing clients;
restore backward compatibility by accepting the previous payload shape and
legacy action values: update handleNetworkModifications to detect both the new
Pair<List<UUID>,List<ModificationApplicationContext>> body and the old payload
(e.g., a wrapper object or plain List<UUID>) and branch accordingly, and allow
legacy GroupModificationAction values (or map them to the new enum) when parsing
the action request param; mark the old-path handling as deprecated (log a
deprecation warning) so you can safely remove it in the next release.

return switch (action) {
case COPY ->
networkModificationService.duplicateModifications(targetGroupUuid, originGroupUuid, modificationsUuids, modificationContextInfos.getSecond()).thenApply(ResponseEntity.ok()::body);
case SPLIT_COMPOSITE ->
networkModificationService.splitCompositeModifications(targetGroupUuid, modificationsUuids, modificationContextInfos.getSecond()).thenApply(ResponseEntity.ok()::body);
case INSERT_COMPOSITE ->
networkModificationService.insertCompositeModificationsIntoGroup(
networkModificationService.duplicateModifications(
targetGroupUuid,
originGroupUuid,
modificationContextInfos.getFirst(),
modificationContextInfos.getSecond()
).thenApply(ResponseEntity.ok()::body);
Expand All @@ -124,7 +117,14 @@ public CompletableFuture<ResponseEntity<NetworkModificationsResult>> handleNetwo
if (sourceGroupUuid.equals(targetGroupUuid)) {
applyModifications = false;
}
yield networkModificationService.moveModifications(targetGroupUuid, sourceGroupUuid, beforeModificationUuid, modificationsUuids, modificationContextInfos.getSecond(), applyModifications).thenApply(ResponseEntity.ok()::body);
yield networkModificationService.moveModifications(
targetGroupUuid,
sourceGroupUuid,
beforeModificationUuid,
modificationContextInfos.getFirst(),
modificationContextInfos.getSecond(),
applyModifications
).thenApply(ResponseEntity.ok()::body);
}
};
}
Expand Down Expand Up @@ -246,40 +246,6 @@ public ResponseEntity<Void> deleteLineTypesCatalog() {
return ResponseEntity.ok().build();
}

@PostMapping(value = "/network-composite-modifications", consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "Create a network composite modification")
@ApiResponses(value = {@ApiResponse(responseCode = "200", description = "The composite modification has been created")})
public ResponseEntity<UUID> createNetworkCompositeModification(@RequestBody List<UUID> modificationUuids) {
return ResponseEntity.ok().body(networkModificationService.createNetworkCompositeModification(modificationUuids));
}

@GetMapping(value = "/network-composite-modifications/network-modifications", produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "Get the list of all the network modifications inside a list of composite modifications")
@ApiResponse(responseCode = "200", description = "List of modifications inside the composite modifications")
public ResponseEntity<List<ModificationInfos>> getNetworkModificationsFromComposite(@Parameter(description = "Composite modifications uuids list") @RequestParam("uuids") List<UUID> compositeModificationUuids,
@Parameter(description = "Only metadata") @RequestParam(name = "onlyMetadata", required = false, defaultValue = "true") Boolean onlyMetadata) {
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(networkModificationService.getNetworkModificationsFromComposite(compositeModificationUuids, onlyMetadata)
);
}

@PostMapping(value = "/network-composite-modifications/duplication", consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "Duplicate some composite modifications")
@ApiResponses(value = {@ApiResponse(responseCode = "200", description = "The duplicated modifications uuids mapped with their source uuid")})
public ResponseEntity<Map<UUID, UUID>> duplicateCompositeModifications(@Parameter(description = "source modifications uuids list to duplicate") @RequestBody List<UUID> sourceModificationUuids) {
return ResponseEntity.ok().body(networkModificationService.duplicateCompositeModifications(sourceModificationUuids));
}

@PutMapping(value = "/network-composite-modifications/{uuid}", consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "Update a network composite modification")
@ApiResponses(value = {@ApiResponse(responseCode = "200", description = "The composite modification has been updated")})
public ResponseEntity<Void> updateNetworkCompositeModification(@PathVariable("uuid") UUID compositeModificationUuid,
@RequestBody List<UUID> modificationUuids) {
networkModificationService.updateCompositeModification(compositeModificationUuid, modificationUuids);
return ResponseEntity.ok().build();
}

@PutMapping(value = "/network-modifications", produces = MediaType.APPLICATION_JSON_VALUE, params = "stashed")
@Operation(summary = "stash or unstash network modifications")
@ApiResponse(responseCode = "200", description = "The network modifications were stashed")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
import org.gridsuite.modification.server.entities.equipment.modification.EquipmentModificationEntity;
import org.gridsuite.modification.server.entities.tabular.TabularModificationsEntity;
import org.gridsuite.modification.server.entities.tabular.TabularPropertyEntity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.util.Pair;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

Expand Down Expand Up @@ -64,6 +67,8 @@ public class NetworkModificationRepository {

private static final String MODIFICATION_NOT_FOUND_MESSAGE = "Modification (%s) not found";

private static final Logger LOGGER = LoggerFactory.getLogger(NetworkModificationRepository.class);

public NetworkModificationRepository(ModificationGroupRepository modificationGroupRepository,
ModificationRepository modificationRepository,
GeneratorCreationRepository generatorCreationRepository,
Expand Down Expand Up @@ -139,15 +144,6 @@ public UUID createNetworkCompositeModification(@NonNull List<UUID> modificationU
return modificationRepository.save(compositeEntity).getId();
}

public CompositeModificationInfos cloneCompositeModification(@NonNull ModificationsToCopyInfos compositeModification) {
CompositeModificationInfos newCompositeInfos = CompositeModificationInfos.builder().modifications(List.of()).build();
List<ModificationInfos> copiedModifications = getCompositeModificationsInfosNonTransactional(List.of(compositeModification.getUuid())).stream()
.toList();
newCompositeInfos.setModifications(copiedModifications);
newCompositeInfos.setName(compositeModification.getCompositeName());
return newCompositeInfos;
}

public void updateCompositeModification(@NonNull UUID compositeUuid, @NonNull List<UUID> modificationUuids) {
ModificationEntity modificationEntity = modificationRepository.findById(compositeUuid)
.orElseThrow(() -> new NetworkModificationException(MODIFICATION_NOT_FOUND, String.format(MODIFICATION_NOT_FOUND_MESSAGE, compositeUuid)));
Expand Down Expand Up @@ -789,13 +785,23 @@ public List<ModificationInfos> saveCompositeModifications(@NonNull UUID targetGr
}

@Transactional
public List<ModificationInfos> insertCompositeModificationsIntoGroup(
public List<ModificationInfos> insertCompositeModifications(
@NonNull UUID targetGroupUuid,
@NonNull List<ModificationsToCopyInfos> compositeModifications) {
@NonNull List<Pair<UUID, String>> compositesUuidName) {
List<UUID> compositeUuids = compositesUuidName.stream().map(Pair::getFirst).toList();
List<ModificationInfos> newCompositeModifications = new ArrayList<>();
for (ModificationsToCopyInfos compositeModification : compositeModifications) {
CompositeModificationInfos newCompositeModification = cloneCompositeModification(compositeModification);
newCompositeModifications.add(newCompositeModification);
List<ModificationInfos> modificationInfos = getModificationsInfosNonTransactional(compositeUuids);
// apply the new composite name to the corresponding composite modifications
for (Pair<UUID, String> compositeUuidName : compositesUuidName) {
CompositeModificationInfos newCompositeModification = (CompositeModificationInfos) modificationInfos.stream()
.filter(modif -> modif.getUuid().equals(compositeUuidName.getFirst()))
.findFirst().orElse(null);
if (newCompositeModification != null) {
newCompositeModification.setName(compositeUuidName.getSecond());
newCompositeModifications.add(newCompositeModification);
} else {
LOGGER.error("Could not find composite modification with uuid {} to apply its name {}", compositeUuidName.getFirst(), compositeUuidName.getSecond());
}
Comment on lines +790 to +804
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate the fetched DTO type and consume one match per requested insert.

Line 796 can throw a ClassCastException when a non-composite UUID slips into this endpoint, and the findFirst() lookup on Lines 796-798 reuses the same DTO when the same composite UUID is inserted twice. For a request like [(A, "name-1"), (A, "name-2")], both saved copies end up carrying the last assigned name. Build a validated Map<UUID, Deque<CompositeModificationInfos>> (or fetch composites only) and poll() one DTO per input pair.

💡 Sketch of a safe lookup
-        List<ModificationInfos> modificationInfos = getModificationsInfosNonTransactional(compositeUuids);
-        // apply the new composite name to the corresponding composite modifications
-        for (Pair<UUID, String> compositeUuidName : compositesUuidName) {
-            CompositeModificationInfos newCompositeModification = (CompositeModificationInfos) modificationInfos.stream()
-                    .filter(modif -> modif.getUuid().equals(compositeUuidName.getFirst()))
-                    .findFirst().orElse(null);
+        Map<UUID, Deque<CompositeModificationInfos>> compositesById = getModificationsInfosNonTransactional(compositeUuids).stream()
+                .map(modif -> {
+                    if (!(modif instanceof CompositeModificationInfos composite)) {
+                        throw new NetworkModificationException(MODIFICATION_ERROR,
+                                String.format("Modification (%s) is not a composite modification", modif.getUuid()));
+                    }
+                    return composite;
+                })
+                .collect(Collectors.groupingBy(
+                        CompositeModificationInfos::getUuid,
+                        LinkedHashMap::new,
+                        Collectors.toCollection(ArrayDeque::new)
+                ));
+        for (Pair<UUID, String> compositeUuidName : compositesUuidName) {
+            CompositeModificationInfos newCompositeModification = Optional.ofNullable(compositesById.get(compositeUuidName.getFirst()))
+                    .map(Deque::pollFirst)
+                    .orElse(null);
             if (newCompositeModification != null) {
                 newCompositeModification.setName(compositeUuidName.getSecond());
                 newCompositeModifications.add(newCompositeModification);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/gridsuite/modification/server/repositories/NetworkModificationRepository.java`
around lines 790 - 804, The loop in the method that processes compositesUuidName
can throw ClassCastException and reuses the same DTO for duplicate UUIDs; fix by
first building a Map<UUID, Deque<CompositeModificationInfos>> from the
List<ModificationInfos> returned by getModificationsInfosNonTransactional(...)
by filtering only instances of CompositeModificationInfos and grouping them into
Deque queues per UUID, then iterate compositesUuidName and for each Pair poll()
one CompositeModificationInfos from the map entry for
compositeUuidName.getFirst(), setName(compositeUuidName.getSecond()) on that
polled instance and add it to newCompositeModifications; if the map has no entry
or the deque is empty, log via LOGGER.error that the composite UUID/name could
not be applied.

}
List<ModificationEntity> newEntities = saveModificationInfosNonTransactional(targetGroupUuid, newCompositeModifications);
return newEntities.stream().map(ModificationEntity::toModificationInfos).toList();
Expand Down
Loading
Loading