Remove legacy type endpoints in favor of catalogs

This commit is contained in:
Lucio Lelii 2026-04-21 12:38:24 +02:00
parent 41c2f844be
commit f729c8a460
6 changed files with 347 additions and 8 deletions

View File

@ -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())

View File

@ -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<JsonNode> schemas) {
}
public SchemaBundle bundle(List<? extends JsonNode> sourceSchemas) {
List<JsonNode> schemas = new ArrayList<>(sourceSchemas.size());
for (JsonNode schema : sourceSchemas) {
schemas.add(schema == null ? null : schema.deepCopy());
}
Map<String, JsonNode> sharedDefinitions = collectSharedDefinitions(schemas);
if (sharedDefinitions.isEmpty()) {
return new SchemaBundle(objectMapper.createObjectNode(), schemas);
}
Set<String> sharedNames = sharedDefinitions.keySet();
ObjectNode sharedDefinitionsNode = objectMapper.createObjectNode();
for (Entry<String, JsonNode> 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<String, JsonNode> collectSharedDefinitions(List<JsonNode> schemas) {
Map<String, DefinitionStats> definitionsByName = new LinkedHashMap<>();
for (JsonNode schema : schemas) {
ObjectNode definitions = definitionsNode(schema);
if (definitions == null) {
continue;
}
for (Entry<String, JsonNode> entry : iterable(definitions.fields())) {
definitionsByName
.computeIfAbsent(entry.getKey(), ignored -> new DefinitionStats())
.register(entry.getValue());
}
}
Map<String, JsonNode> sharedDefinitions = new LinkedHashMap<>();
for (Entry<String, DefinitionStats> entry : definitionsByName.entrySet()) {
if (entry.getValue().isShareable()) {
sharedDefinitions.put(entry.getKey(), entry.getValue().sample());
}
}
return sharedDefinitions;
}
private void rewriteSharedRefs(JsonNode node, Set<String> 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<String> 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<String> 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 <T> Iterable<T> iterable(java.util.Iterator<T> iterator) {
return () -> iterator;
}
private static final class DefinitionStats {
private int occurrences;
private final Set<String> 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;
}
}
}

View File

@ -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<BlockConfigurationDescriptor> 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<BlockConfigurationDescriptor> 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<BlockConfigurationDescriptor> descriptors = getTypes();
JsonSchemaCatalogBundler.SchemaBundle bundle = schemaCatalogBundler.bundle(descriptors.stream()
.map(BlockConfigurationDescriptor::schema)
.map(schema -> schema instanceof JsonNode jsonNode ? jsonNode : null)
.toList());
List<BlockConfigurationDescriptor> 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<BlockConfigurationDescriptor> 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(

View File

@ -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<String, ContainerType> containerTypes,
List<ContainerFactory<?, ?>> 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<ContainerConfigurationDescriptor> descriptors) {
}
public static class ContainerSubFlowValidationRequest {
private FlowData subFlow;
@ -88,12 +100,26 @@ public class ContainersController {
List<ContainerHandleDescriptor> openOutputs) {
}
@GetMapping("types")
@Operation(summary = "Get container types", description = "Returns all container types with their configuration descriptor and JSON schema.")
public List<ContainerConfigurationDescriptor> 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<ContainerConfigurationDescriptor> descriptors = getTypes();
JsonSchemaCatalogBundler.SchemaBundle bundle = schemaCatalogBundler.bundle(descriptors.stream()
.map(ContainerConfigurationDescriptor::schema)
.map(schema -> schema instanceof JsonNode jsonNode ? jsonNode : null)
.toList());
List<ContainerConfigurationDescriptor> 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<? extends ContainerConfiguration<?>> 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);
}
}

View File

@ -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()

View File

@ -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<ContainersController.ContainerConfigurationDescriptor> 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<GenericContainerType> container = containersController.getExampleForType(GenericContainerType.TYPE);