From f729c8a460f91714c301747247e189effcaa52bc Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Tue, 21 Apr 2026 12:38:24 +0200 Subject: [PATCH] Remove legacy type endpoints in favor of catalogs --- .../manager/auth/config/SecurityConfig.java | 2 - .../JsonSchemaCatalogBundler.java | 184 ++++++++++++++++++ .../manager/controllers/BlocksController.java | 51 ++++- .../controllers/ContainersController.java | 43 +++- .../controllers/BlocksControllerTest.java | 33 ++++ .../controllers/ContainersControllerTest.java | 42 ++++ 6 files changed, 347 insertions(+), 8 deletions(-) create mode 100644 src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/JsonSchemaCatalogBundler.java diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/config/SecurityConfig.java b/src/main/java/it/cnr/isti/workflow/manager/auth/config/SecurityConfig.java index 6785831..d9f9a71 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/auth/config/SecurityConfig.java +++ b/src/main/java/it/cnr/isti/workflow/manager/auth/config/SecurityConfig.java @@ -38,9 +38,7 @@ public class SecurityConfig { .requestMatchers("/v3/api-docs/**").permitAll() .requestMatchers("/actuator/**").permitAll() .requestMatchers("/error").permitAll() - .requestMatchers("/blocks/types").permitAll() .requestMatchers("/blocks/types/**").permitAll() - .requestMatchers("/containers/types").permitAll() .requestMatchers("/containers/types/**").permitAll() .requestMatchers("/retriever/**").permitAll() .anyRequest().authenticated()) diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/JsonSchemaCatalogBundler.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/JsonSchemaCatalogBundler.java new file mode 100644 index 0000000..5a32909 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/JsonSchemaCatalogBundler.java @@ -0,0 +1,184 @@ +package it.cnr.isti.workflow.manager.blocks.configurations; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +@Component +public class JsonSchemaCatalogBundler { + + private static final String DEFINITIONS_FIELD = "definitions"; + private static final String DEFS_FIELD = "$defs"; + private static final String SHARED_DEFINITIONS_FIELD = "sharedDefinitions"; + private static final String DEFINITIONS_REF_PREFIX = "#/definitions/"; + private static final String DEFS_REF_PREFIX = "#/$defs/"; + private static final String SHARED_DEFINITIONS_REF_PREFIX = "#/" + SHARED_DEFINITIONS_FIELD + "/"; + + private final ObjectMapper objectMapper; + + public JsonSchemaCatalogBundler(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public record SchemaBundle(ObjectNode sharedDefinitions, List schemas) { + } + + public SchemaBundle bundle(List sourceSchemas) { + List schemas = new ArrayList<>(sourceSchemas.size()); + for (JsonNode schema : sourceSchemas) { + schemas.add(schema == null ? null : schema.deepCopy()); + } + + Map sharedDefinitions = collectSharedDefinitions(schemas); + if (sharedDefinitions.isEmpty()) { + return new SchemaBundle(objectMapper.createObjectNode(), schemas); + } + + Set sharedNames = sharedDefinitions.keySet(); + ObjectNode sharedDefinitionsNode = objectMapper.createObjectNode(); + for (Entry entry : sharedDefinitions.entrySet()) { + JsonNode definition = entry.getValue().deepCopy(); + rewriteSharedRefs(definition, sharedNames); + sharedDefinitionsNode.set(entry.getKey(), definition); + } + + for (JsonNode schema : schemas) { + if (!(schema instanceof ObjectNode root)) { + continue; + } + rewriteSharedRefs(root, sharedNames); + removeSharedDefinitions(root, sharedNames); + } + + return new SchemaBundle(sharedDefinitionsNode, schemas); + } + + private Map collectSharedDefinitions(List schemas) { + Map definitionsByName = new LinkedHashMap<>(); + for (JsonNode schema : schemas) { + ObjectNode definitions = definitionsNode(schema); + if (definitions == null) { + continue; + } + for (Entry entry : iterable(definitions.fields())) { + definitionsByName + .computeIfAbsent(entry.getKey(), ignored -> new DefinitionStats()) + .register(entry.getValue()); + } + } + + Map sharedDefinitions = new LinkedHashMap<>(); + for (Entry entry : definitionsByName.entrySet()) { + if (entry.getValue().isShareable()) { + sharedDefinitions.put(entry.getKey(), entry.getValue().sample()); + } + } + return sharedDefinitions; + } + + private void rewriteSharedRefs(JsonNode node, Set sharedNames) { + if (node instanceof ObjectNode objectNode) { + JsonNode refNode = objectNode.get("$ref"); + if (refNode != null && refNode.isTextual()) { + String rewritten = rewriteSharedRef(refNode.asText(), sharedNames); + if (!rewritten.equals(refNode.asText())) { + objectNode.put("$ref", rewritten); + } + } + for (JsonNode child : iterable(objectNode.elements())) { + rewriteSharedRefs(child, sharedNames); + } + return; + } + if (node instanceof ArrayNode arrayNode) { + for (JsonNode child : arrayNode) { + rewriteSharedRefs(child, sharedNames); + } + } + } + + private String rewriteSharedRef(String ref, Set sharedNames) { + if (ref.startsWith(DEFINITIONS_REF_PREFIX)) { + String definitionName = ref.substring(DEFINITIONS_REF_PREFIX.length()); + if (sharedNames.contains(definitionName)) { + return SHARED_DEFINITIONS_REF_PREFIX + definitionName; + } + } + if (ref.startsWith(DEFS_REF_PREFIX)) { + String definitionName = ref.substring(DEFS_REF_PREFIX.length()); + if (sharedNames.contains(definitionName)) { + return SHARED_DEFINITIONS_REF_PREFIX + definitionName; + } + } + return ref; + } + + private void removeSharedDefinitions(ObjectNode root, Set sharedNames) { + ObjectNode definitions = definitionsNode(root); + if (definitions == null) { + return; + } + + sharedNames.forEach(definitions::remove); + if (definitions.isEmpty()) { + if (root.has(DEFINITIONS_FIELD)) { + root.remove(DEFINITIONS_FIELD); + } + if (root.has(DEFS_FIELD)) { + root.remove(DEFS_FIELD); + } + } + } + + private ObjectNode definitionsNode(JsonNode schema) { + if (!(schema instanceof ObjectNode root)) { + return null; + } + JsonNode definitions = root.get(DEFINITIONS_FIELD); + if (definitions instanceof ObjectNode objectNode) { + return objectNode; + } + JsonNode defs = root.get(DEFS_FIELD); + if (defs instanceof ObjectNode objectNode) { + return objectNode; + } + return null; + } + + private static Iterable iterable(java.util.Iterator iterator) { + return () -> iterator; + } + + private static final class DefinitionStats { + private int occurrences; + private final Set fingerprints = new LinkedHashSet<>(); + private JsonNode sample; + + void register(JsonNode definition) { + occurrences++; + fingerprints.add(definition.toString()); + if (sample == null) { + sample = definition.deepCopy(); + } + } + + boolean isShareable() { + return occurrences >= 2 && fingerprints.size() == 1; + } + + JsonNode sample() { + return sample; + } + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/controllers/BlocksController.java b/src/main/java/it/cnr/isti/workflow/manager/controllers/BlocksController.java index 1158125..0935468 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/controllers/BlocksController.java +++ b/src/main/java/it/cnr/isti/workflow/manager/controllers/BlocksController.java @@ -6,6 +6,7 @@ import org.springframework.web.bind.annotation.RestController; import it.cnr.isti.workflow.manager.app.ObjectMapperHolder; import it.cnr.isti.workflow.manager.blocks.Block; import it.cnr.isti.workflow.manager.blocks.configurations.BlockConfiguration; +import it.cnr.isti.workflow.manager.blocks.configurations.JsonSchemaCatalogBundler; import it.cnr.isti.workflow.manager.blocks.configurations.JsonSchemaProducer; import it.cnr.isti.workflow.manager.blocks.factories.BlockFactory; import it.cnr.isti.workflow.manager.blocks.types.BlockType; @@ -13,6 +14,7 @@ import it.cnr.isti.workflow.manager.executions.design.FlowSharedVariableCatalogS import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; @@ -24,6 +26,7 @@ import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; import org.springframework.web.server.ResponseStatusException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.core.JsonProcessingException; @RestController @@ -42,6 +45,9 @@ public class BlocksController { @Autowired JsonSchemaProducer schemaProducer; + @Autowired + JsonSchemaCatalogBundler schemaCatalogBundler; + @Autowired FlowSharedVariableCatalogService flowSharedVariableCatalogService; @@ -57,6 +63,11 @@ public class BlocksController { Object schema) { } + public record BlockConfigurationCatalog( + JsonNode sharedDefinitions, + List descriptors) { + } + public record InteractionContractDescriptor( String kind, String messageField, @@ -66,19 +77,38 @@ public class BlocksController { boolean supportsPartialResult) { } - - @GetMapping("types") - @Operation(summary = "Get block types", description = "Returns all block types with their configuration descriptor and JSON schema.") public List getTypes() { return blockTypes.values().stream().map(this::toDescriptor).toList(); } + @GetMapping("/types/catalog") + @Operation(summary = "Get compact block type catalog", description = "Returns all block types with JSON schemas compacted through shared schema definitions.") + public BlockConfigurationCatalog getTypeCatalog() { + List descriptors = getTypes(); + JsonSchemaCatalogBundler.SchemaBundle bundle = schemaCatalogBundler.bundle(descriptors.stream() + .map(BlockConfigurationDescriptor::schema) + .map(schema -> schema instanceof JsonNode jsonNode ? jsonNode : null) + .toList()); + + List compactDescriptors = IntStream.range(0, descriptors.size()) + .mapToObj(index -> withSchema(descriptors.get(index), bundle.schemas().get(index))) + .toList(); + + return new BlockConfigurationCatalog(bundle.sharedDefinitions(), compactDescriptors); + } + @GetMapping("/types/configurations/descriptors") - @Operation(summary = "Get block configuration descriptors", description = "Alias of /blocks/types. Returns one descriptor for each block type, including JSON schema.") + @Operation(summary = "Get block configuration descriptors", description = "Returns one descriptor for each block type, including JSON schema.") public List getConfigurationDescriptors() { return getTypes(); } + @GetMapping("/types/configurations/catalog") + @Operation(summary = "Get compact block configuration descriptors", description = "Alias of /blocks/types/catalog. Returns one compact descriptor for each block type, with shared schema definitions lifted to the response root.") + public BlockConfigurationCatalog getConfigurationCatalog() { + return getTypeCatalog(); + } + @GetMapping("/types/{type}/configuration/descriptor") @Operation(summary = "Get block configuration descriptor by type", description = "Returns the descriptor and JSON schema for the requested block type.") public BlockConfigurationDescriptor getConfigurationDescriptorForType(@PathVariable String type) { @@ -139,6 +169,19 @@ public class BlocksController { return "/blocks/types/" + blockType.getName() + "/example"; } + private BlockConfigurationDescriptor withSchema(BlockConfigurationDescriptor descriptor, JsonNode schema) { + return new BlockConfigurationDescriptor( + descriptor.type(), + descriptor.description(), + descriptor.userInteractive(), + descriptor.interactionContract(), + descriptor.hasExampleBlock(), + descriptor.exampleBlockEndpoint(), + descriptor.configurationType(), + descriptor.configurationClass(), + schema); + } + private InteractionContractDescriptor resolveInteractionContract(BlockType blockType) { if ("ChatInteraction".equals(blockType.getName()) || "MCPAgentChat".equals(blockType.getName())) { return new InteractionContractDescriptor( diff --git a/src/main/java/it/cnr/isti/workflow/manager/controllers/ContainersController.java b/src/main/java/it/cnr/isti/workflow/manager/controllers/ContainersController.java index 4629d0b..3277bed 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/controllers/ContainersController.java +++ b/src/main/java/it/cnr/isti/workflow/manager/controllers/ContainersController.java @@ -2,6 +2,7 @@ package it.cnr.isti.workflow.manager.controllers; import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; @@ -21,9 +22,11 @@ import it.cnr.isti.workflow.manager.containers.factories.ContainerFactory; import it.cnr.isti.workflow.manager.containers.iresolvers.ContainerFlowInterfaceResolver; import it.cnr.isti.workflow.manager.containers.types.ContainerType; import it.cnr.isti.workflow.manager.blocks.configurations.JsonSchemaProducer; +import it.cnr.isti.workflow.manager.blocks.configurations.JsonSchemaCatalogBundler; import it.cnr.isti.workflow.manager.flows.model.FlowData; import it.cnr.isti.workflow.manager.flows.validation.ValidationError; import it.cnr.isti.workflow.manager.ios.IODescriptor; +import com.fasterxml.jackson.databind.JsonNode; @RestController @RequestMapping("/containers") @@ -35,16 +38,20 @@ public class ContainersController { JsonSchemaProducer schemaProducer; + private final JsonSchemaCatalogBundler schemaCatalogBundler; + private final ContainerSubFlowValidator containerSubFlowValidator; public ContainersController( Map containerTypes, List> containerFactories, JsonSchemaProducer schemaProducer, + JsonSchemaCatalogBundler schemaCatalogBundler, ContainerSubFlowValidator containerSubFlowValidator) { this.containerTypes = containerTypes; this.containerFactories = containerFactories; this.schemaProducer = schemaProducer; + this.schemaCatalogBundler = schemaCatalogBundler; this.containerSubFlowValidator = containerSubFlowValidator; } @@ -59,6 +66,11 @@ public class ContainersController { Object schema) { } + public record ContainerConfigurationCatalog( + JsonNode sharedDefinitions, + List descriptors) { + } + public static class ContainerSubFlowValidationRequest { private FlowData subFlow; @@ -88,12 +100,26 @@ public class ContainersController { List openOutputs) { } - @GetMapping("types") - @Operation(summary = "Get container types", description = "Returns all container types with their configuration descriptor and JSON schema.") public List getTypes() { return containerTypes.values().stream().map(this::toDescriptor).toList(); } + @GetMapping("types/catalog") + @Operation(summary = "Get compact container type catalog", description = "Returns all container types with JSON schemas compacted through shared schema definitions.") + public ContainerConfigurationCatalog getTypeCatalog() { + List descriptors = getTypes(); + JsonSchemaCatalogBundler.SchemaBundle bundle = schemaCatalogBundler.bundle(descriptors.stream() + .map(ContainerConfigurationDescriptor::schema) + .map(schema -> schema instanceof JsonNode jsonNode ? jsonNode : null) + .toList()); + + List compactDescriptors = IntStream.range(0, descriptors.size()) + .mapToObj(index -> withSchema(descriptors.get(index), bundle.schemas().get(index))) + .toList(); + + return new ContainerConfigurationCatalog(bundle.sharedDefinitions(), compactDescriptors); + } + @GetMapping("/types/{type}/example") @Operation(summary = "Get container example by type", description = "Returns an empty example container for the requested container type.") @SuppressWarnings("unchecked") @@ -139,6 +165,7 @@ public class ContainersController { private ContainerHandleDescriptor toHandle(ContainerFlowInterfaceResolver.OpenHandle handle) { return new ContainerHandleDescriptor(handle.blockId(), handle.blockName(), handle.io()); } + private ContainerConfigurationDescriptor toDescriptor(ContainerType containerType) { Class> configurationClass = containerType.getContainerConfigurationClass(); return new ContainerConfigurationDescriptor( @@ -151,4 +178,16 @@ public class ContainersController { configurationClass == null ? null : configurationClass.getName(), configurationClass == null ? null : schemaProducer.generateSchemaNode(configurationClass)); } + + private ContainerConfigurationDescriptor withSchema(ContainerConfigurationDescriptor descriptor, JsonNode schema) { + return new ContainerConfigurationDescriptor( + descriptor.type(), + descriptor.description(), + descriptor.userInteractive(), + descriptor.hasExampleContainer(), + descriptor.exampleContainerEndpoint(), + descriptor.configurationType(), + descriptor.configurationClass(), + schema); + } } diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java index 3afd590..123400f 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java @@ -14,6 +14,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.ResponseEntity; import org.springframework.test.context.TestPropertySource; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; import com.fasterxml.jackson.databind.JsonNode; @@ -59,6 +60,9 @@ public class BlocksControllerTest { @Autowired private ExecutionVariablesFieldRetriever executionVariablesFieldRetriever; + @Autowired + private RequestMappingHandlerMapping requestMappingHandlerMapping; + private LLMDescriptor simulatorDescriptor() { return LLMDescriptor.builder() .provider("testProvider") @@ -78,6 +82,35 @@ public class BlocksControllerTest { System.out.println("Type: " + type.type()); } + @Test + public void getTypeCatalogExtractsSharedBlockDefinitions() { + BlocksController.BlockConfigurationCatalog catalog = blocksController.getTypeCatalog(); + + assertNotNull(catalog); + assertNotNull(catalog.sharedDefinitions()); + assertTrue(catalog.sharedDefinitions().has("LLMDescriptor")); + assertEquals(blocksController.getTypes().size(), catalog.descriptors().size()); + + JsonNode llmSchema = (JsonNode) catalog.descriptors().stream() + .filter(type -> LLMBlockType.TYPE.equals(type.type())) + .findFirst() + .orElseThrow() + .schema(); + + assertEquals("#/sharedDefinitions/LLMDescriptor", + llmSchema.path("properties").path("llmDescriptor").path("$ref").asText()); + assertFalse(llmSchema.path("definitions").has("LLMDescriptor")); + assertEquals("/retriever/LLM/providers", + catalog.sharedDefinitions().path("LLMDescriptor").path("properties").path("provider").path("x-retriever-url").asText()); + } + + @Test + public void blockTypesListEndpointIsNotRegistered() { + assertFalse(requestMappingHandlerMapping.getHandlerMethods().keySet().stream() + .flatMap(info -> info.getPatternValues().stream()) + .anyMatch("/blocks/types"::equals)); + } + @Test public void create(){ LLMBlockConfiguration config = LLMBlockConfiguration.builder() diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/ContainersControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/ContainersControllerTest.java index 7c39a52..0a5e740 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/ContainersControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/ContainersControllerTest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.TestPropertySource; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; import com.fasterxml.jackson.databind.JsonNode; @@ -39,6 +40,9 @@ public class ContainersControllerTest { @Autowired private BlocksController blocksController; + @Autowired + private RequestMappingHandlerMapping requestMappingHandlerMapping; + @Test public void getTypes() { List types = containersController.getTypes(); @@ -51,6 +55,44 @@ public class ContainersControllerTest { assertTrue(types.stream().allMatch(type -> type.exampleContainerEndpoint().equals("/containers/types/" + type.type() + "/example"))); } + @Test + public void getTypeCatalogExtractsSharedContainerDefinitions() { + ContainersController.ContainerConfigurationCatalog catalog = containersController.getTypeCatalog(); + + assertNotNull(catalog); + assertNotNull(catalog.sharedDefinitions()); + assertTrue(catalog.sharedDefinitions().has("FlowData")); + assertTrue(catalog.sharedDefinitions().has("Position")); + assertEquals(containersController.getTypes().size(), catalog.descriptors().size()); + + JsonNode genericSchema = (JsonNode) catalog.descriptors().stream() + .filter(type -> GenericContainerType.TYPE.equals(type.type())) + .findFirst() + .orElseThrow() + .schema(); + + assertEquals("#/sharedDefinitions/FlowData", genericSchema.path("properties").path("subFlow").path("$ref").asText()); + assertFalse(genericSchema.path("definitions").has("FlowData")); + + JsonNode loopSchema = (JsonNode) catalog.descriptors().stream() + .filter(type -> LoopContainerType.TYPE.equals(type.type())) + .findFirst() + .orElseThrow() + .schema(); + + assertFalse(loopSchema.path("definitions").has("FlowData")); + assertTrue(loopSchema.path("definitions").has("LLMDescriptor")); + assertEquals("#/sharedDefinitions/IODescriptor", + catalog.sharedDefinitions().path("FlowData").path("properties").path("globalInputs").path("items").path("$ref").asText()); + } + + @Test + public void containerTypesListEndpointIsNotRegistered() { + assertFalse(requestMappingHandlerMapping.getHandlerMethods().keySet().stream() + .flatMap(info -> info.getPatternValues().stream()) + .anyMatch("/containers/types"::equals)); + } + @Test public void getGenericContainerExampleForType() { Container container = containersController.getExampleForType(GenericContainerType.TYPE);