diff --git a/runtime/binding-mcp-kafka-connect/src/main/resources/io/aklivity/zilla/runtime/binding/mcp/kafka/connect/internal/schema/kafka-connect.openapi.json b/runtime/binding-mcp-kafka-connect/src/main/resources/io/aklivity/zilla/runtime/binding/mcp/kafka/connect/internal/schema/kafka-connect.openapi.json index b102237a811..e21c1c49fc3 100644 --- a/runtime/binding-mcp-kafka-connect/src/main/resources/io/aklivity/zilla/runtime/binding/mcp/kafka/connect/internal/schema/kafka-connect.openapi.json +++ b/runtime/binding-mcp-kafka-connect/src/main/resources/io/aklivity/zilla/runtime/binding/mcp/kafka/connect/internal/schema/kafka-connect.openapi.json @@ -144,7 +144,8 @@ "tasks.max": { "type": "string" } - } + }, + "additionalProperties": true } } } @@ -183,7 +184,8 @@ "tasks.max": { "type": "string" } - } + }, + "additionalProperties": true } } } diff --git a/runtime/binding-mcp-openapi/src/main/java/io/aklivity/zilla/runtime/binding/mcp/openapi/internal/config/composite/McpOpenapiCompositeGenerator.java b/runtime/binding-mcp-openapi/src/main/java/io/aklivity/zilla/runtime/binding/mcp/openapi/internal/config/composite/McpOpenapiCompositeGenerator.java index 725694bd0a8..e73333dafc6 100644 --- a/runtime/binding-mcp-openapi/src/main/java/io/aklivity/zilla/runtime/binding/mcp/openapi/internal/config/composite/McpOpenapiCompositeGenerator.java +++ b/runtime/binding-mcp-openapi/src/main/java/io/aklivity/zilla/runtime/binding/mcp/openapi/internal/config/composite/McpOpenapiCompositeGenerator.java @@ -1186,13 +1186,57 @@ private static String bodySchema( { if (typed.schema != null) { - result = toSchemaJson(jsonb, typed.schema.model); + result = denyParameterNames(toSchemaJson(jsonb, typed.schema.model), operation); } break; } return result; } + // A structured body schema (named properties, possibly widened by additionalProperties) shares the + // same tools/call arguments object as the operation's own path/query/header/cookie parameters. Once + // additionalProperties admits unnamed keys, a parameter name would otherwise pass straight through + // that wildcard into the outbound body alongside the fields it actually describes -- so every + // parameter not already a declared body property is denied explicitly (a false sub-schema), which + // JsonSchema#rejectedPaths() surfaces to the body projector as always winning over the wildcard. + private static String denyParameterNames( + String schemaJson, + OpenapiOperationView operation) + { + String result = schemaJson; + if (operation.parameters != null && !operation.parameters.isEmpty()) + { + JsonValue parsed = Json.createReader(new StringReader(schemaJson)).readValue(); + if (parsed instanceof JsonObject) + { + JsonObject schema = (JsonObject) parsed; + JsonValue properties = schema.get("properties"); + if (properties instanceof JsonObject) + { + JsonObject declared = (JsonObject) properties; + JsonObjectBuilder denied = Json.createObjectBuilder(declared); + boolean modified = false; + for (OpenapiParameterView parameter : operation.parameters) + { + if (!declared.containsKey(parameter.name)) + { + denied.add(parameter.name, JsonValue.FALSE); + modified = true; + } + } + if (modified) + { + result = Json.createObjectBuilder(schema) + .add("properties", denied) + .build() + .toString(); + } + } + } + } + return result; + } + private static JsonObject schemaObject( OpenapiSchemaView schema) { diff --git a/runtime/binding-mcp-openapi/src/test/java/io/aklivity/zilla/runtime/binding/mcp/openapi/internal/config/composite/McpOpenapiCompositeGeneratorTest.java b/runtime/binding-mcp-openapi/src/test/java/io/aklivity/zilla/runtime/binding/mcp/openapi/internal/config/composite/McpOpenapiCompositeGeneratorTest.java index 693189c5baa..6c944d53dd3 100644 --- a/runtime/binding-mcp-openapi/src/test/java/io/aklivity/zilla/runtime/binding/mcp/openapi/internal/config/composite/McpOpenapiCompositeGeneratorTest.java +++ b/runtime/binding-mcp-openapi/src/test/java/io/aklivity/zilla/runtime/binding/mcp/openapi/internal/config/composite/McpOpenapiCompositeGeneratorTest.java @@ -749,6 +749,86 @@ public void shouldFlattenInputSchemaWithBodyCollisionSuffix() assertThat(required, not(hasItem("owner_body"))); } + @Test + public void shouldDenyPathParameterInOpenBodySchema() + { + String spec = + """ + { + "openapi": "3.0.1", + "info": { "title": "things", "version": "1.0" }, + "servers": [ { "url": "https://api.example.com" } ], + "paths": { + "/things/{id}": { + "put": { + "operationId": "update_thing", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "additionalProperties": true } } } + }, + "responses": { "200": { "description": "ok" } } + } + } + } + } + """; + lenient().when(catalog.resolve(eq("things-api"), eq("latest"))).thenReturn(77); + lenient().when(catalog.resolve(eq(77))).thenReturn(spec); + + BindingConfig binding = GenericBindingConfig.builder() + .namespace("test") + .name("mcp-openapi0") + .type("mcp-openapi") + .kind(CLIENT) + .options(McpOpenapiOptionsConfig.builder() + .spec() + .label("openapi_things0") + .server("https://api.example.com") + .catalog() + .name("catalog0") + .subject("things-api") + .version("latest") + .build() + .build() + .build()) + .route() + .when(McpOpenapiConditionConfig.builder() + .tool("update_thing") + .build()) + .with(McpOpenapiWithConfig.builder() + .spec("openapi_things0") + .operation("update_thing") + .build()) + .build() + .build(); + binding.resolveId = resolveId; + + McpOpenapiCompositeConfig composite = generator.generate(new McpOpenapiBindingConfig(context, binding)); + + NamespaceConfig namespace = composite.namespaces.get(0); + String bodySchema = namespace.catalogs.stream() + .map(c -> c.options) + .filter(InlineOptionsConfig.class::isInstance) + .map(InlineOptionsConfig.class::cast) + .flatMap(o -> o.subjects.stream()) + .filter(s -> "update_thing-body".equals(s.subject)) + .map(s -> s.schema) + .findFirst() + .orElse(null); + + assertThat(bodySchema, notNullValue()); + JsonObject bodySchemaObject = Json.createReader(new StringReader(bodySchema)).readObject(); + assertThat(bodySchemaObject.getJsonObject("properties").getBoolean("id"), equalTo(false)); + assertThat(bodySchemaObject.getJsonObject("properties").containsKey("name"), equalTo(true)); + assertThat(bodySchemaObject.getBoolean("additionalProperties"), equalTo(true)); + } + @Test public void shouldOverrideOutputSchema() { diff --git a/runtime/common-json/src/main/java/io/aklivity/zilla/runtime/common/json/JsonSchema.java b/runtime/common-json/src/main/java/io/aklivity/zilla/runtime/common/json/JsonSchema.java index c145b371b1c..db0e59c5aae 100644 --- a/runtime/common-json/src/main/java/io/aklivity/zilla/runtime/common/json/JsonSchema.java +++ b/runtime/common-json/src/main/java/io/aklivity/zilla/runtime/common/json/JsonSchema.java @@ -142,6 +142,17 @@ JsonTransform validator( */ List retainedPaths(); + /** + * Returns the RFC 6901 JSON Pointers explicitly denied ({@code false}, or an equivalent + * always-fails sub-schema) when projecting an instance of this schema — the union of such paths + * declared across all branches. A path here always wins over the same path being retained + * elsewhere (e.g. a structured schema's own named property denied while its sibling {@code + * additionalProperties} would otherwise keep it): {@link JsonTransforms#projector(JsonSchema)} + * feeds both lists to the same projector, and a rejected path is excluded even where a broader + * wildcard from {@link #retainedPaths()} would otherwise retain it. + */ + List rejectedPaths(); + /** * The compiled sub-schema declared for object property {@code name} under this schema's {@code * properties} keyword, or {@code null} when this schema declares no such property. diff --git a/runtime/common-json/src/main/java/io/aklivity/zilla/runtime/common/json/JsonTransforms.java b/runtime/common-json/src/main/java/io/aklivity/zilla/runtime/common/json/JsonTransforms.java index 86a96e1003d..d23c067d234 100644 --- a/runtime/common-json/src/main/java/io/aklivity/zilla/runtime/common/json/JsonTransforms.java +++ b/runtime/common-json/src/main/java/io/aklivity/zilla/runtime/common/json/JsonTransforms.java @@ -44,12 +44,14 @@ public static JsonTransform projector( /** * Returns a {@link JsonTransform} pruning a document to the paths retained by {@code schema} - * (see {@link JsonSchema#retainedPaths()}). + * (see {@link JsonSchema#retainedPaths()}), excluding any path {@code schema} explicitly denies + * (see {@link JsonSchema#rejectedPaths()}) even where a broader retained path would otherwise + * keep it. */ public static JsonTransform projector( JsonSchema schema) { - return new JsonProjectorImpl(schema.retainedPaths()); + return new JsonProjectorImpl(schema.retainedPaths(), schema.rejectedPaths()); } /** diff --git a/runtime/common-json/src/main/java/io/aklivity/zilla/runtime/common/json/internal/JsonProjectorImpl.java b/runtime/common-json/src/main/java/io/aklivity/zilla/runtime/common/json/internal/JsonProjectorImpl.java index b1b5ce18770..20cde0aac0c 100644 --- a/runtime/common-json/src/main/java/io/aklivity/zilla/runtime/common/json/internal/JsonProjectorImpl.java +++ b/runtime/common-json/src/main/java/io/aklivity/zilla/runtime/common/json/internal/JsonProjectorImpl.java @@ -50,6 +50,7 @@ public final class JsonProjectorImpl implements JsonTransform { private static final int MAX_DEPTH = 64; private static final String WILDCARD = "-"; + private static final String WILDCARD_PROPERTY = "*"; private enum Decision { @@ -105,7 +106,14 @@ private enum SegMode public JsonProjectorImpl( List pointers) { - this.root = compile(pointers); + this(pointers, List.of()); + } + + public JsonProjectorImpl( + List retained, + List rejected) + { + this.root = compile(retained, rejected); } @Override @@ -621,6 +629,14 @@ private static Decision decide( { result = Decision.SKIP; } + else if (node.rejected) + { + // an explicit reject always wins, even over this same node's own keepAll -- a pointer can + // only ever land in one of retained/rejected (JsonSchemaImpl's retainedPaths/rejectedPaths + // partition every leaf by its own deny flag), but a caller combining pointer lists by hand + // should still get the safer, deny-wins outcome on an accidental overlap + result = Decision.SKIP; + } else if (node.keepAll) { result = Decision.KEEP_ALL; @@ -636,7 +652,9 @@ else if (node.keys.length > 0) return result; } - // Matches an object key against a node's children by the live char view, allocation-free. + // Matches an object key against a node's children by the live char view, allocation-free -- preferring + // an explicit named child over the "*" wildcard, the object-key counterpart to lookupIndex's array + // wildcard fallback below. private static Node lookup( Node node, CharSequence key) @@ -644,13 +662,23 @@ private static Node lookup( Node result = null; if (node != null) { + Node wildcard = null; for (int i = 0; result == null && i < node.keys.length; i++) { - if (charsEqual(node.keys[i], key)) + String segment = node.keys[i]; + if (WILDCARD_PROPERTY.equals(segment)) + { + wildcard = node.nodes[i]; + } + else if (charsEqual(segment, key)) { result = node.nodes[i]; } } + if (result == null) + { + result = wildcard; + } } return result; } @@ -722,10 +750,11 @@ private static boolean matchesIndex( } private static Node compile( - List pointers) + List retained, + List rejected) { NodeBuilder builder = new NodeBuilder(); - for (String pointer : pointers) + for (String pointer : retained) { NodeBuilder node = builder; for (String segment : segments(pointer)) @@ -734,6 +763,15 @@ private static Node compile( } node.keepAll = true; } + for (String pointer : rejected) + { + NodeBuilder node = builder; + for (String segment : segments(pointer)) + { + node = node.child(segment); + } + node.rejected = true; + } return builder.build(); } @@ -758,29 +796,39 @@ private static String[] segments( } // An immutable trie node: children are parallel key/node arrays scanned linearly (a handful of children - // per node), keepAll marks a node where a retained pointer terminates, and maxKeyLength is the longest - // of this node's own children's keys — the bound onKey declines a fragmenting child key against. + // per node), keepAll marks a node where a retained pointer terminates, rejected marks a node where a + // rejected pointer terminates (and always wins over this same node's own keepAll, see decide()), and + // maxKeyLength is the longest of this node's own children's keys — the bound onKey declines a + // fragmenting child key against. private static final class Node { private final String[] keys; private final Node[] nodes; private final boolean keepAll; + private final boolean rejected; private final int maxKeyLength; private Node( String[] keys, Node[] nodes, - boolean keepAll) + boolean keepAll, + boolean rejected) { this.keys = keys; this.nodes = nodes; this.keepAll = keepAll; + this.rejected = rejected; int longest = 0; + boolean wildcard = false; for (String key : keys) { longest = Math.max(longest, key.length()); + wildcard |= WILDCARD_PROPERTY.equals(key); } - this.maxKeyLength = longest; + // a "*" child matches a key of any length, so a fragment already longer than every named + // sibling still can't be ruled out early -- only a closed enumeration (no wildcard) can use + // the max-named-length bound to declare SKIP before the key is fully reassembled + this.maxKeyLength = wildcard ? Integer.MAX_VALUE : longest; } } @@ -788,6 +836,7 @@ private static final class NodeBuilder { private final Map children = new LinkedHashMap<>(); private boolean keepAll; + private boolean rejected; private NodeBuilder child( String segment) @@ -807,7 +856,7 @@ private Node build() nodes[i] = entry.getValue().build(); i++; } - return new Node(keys, nodes, keepAll); + return new Node(keys, nodes, keepAll, rejected); } } diff --git a/runtime/common-json/src/main/java/io/aklivity/zilla/runtime/common/json/internal/JsonSchemaImpl.java b/runtime/common-json/src/main/java/io/aklivity/zilla/runtime/common/json/internal/JsonSchemaImpl.java index cc217c0e2a3..072a69428fc 100644 --- a/runtime/common-json/src/main/java/io/aklivity/zilla/runtime/common/json/internal/JsonSchemaImpl.java +++ b/runtime/common-json/src/main/java/io/aklivity/zilla/runtime/common/json/internal/JsonSchemaImpl.java @@ -88,6 +88,11 @@ public final class JsonSchemaImpl implements JsonSchema private static final JsonSchemaImpl ANY = new JsonSchemaImpl(false); private static final JsonSchemaImpl NONE = new JsonSchemaImpl(true); + // reserved retained-path segment standing for "any object key not matched by a named property" -- + // distinct from JsonProjectorImpl's array-only "-" wildcard, since an object key literally named "-" + // is matched like any other key there + private static final String WILDCARD_PROPERTY = "*"; + private static final String[] NO_KEYS = new String[0]; private static final JsonSchemaImpl[] NO_SCHEMAS = new JsonSchemaImpl[0]; @@ -190,6 +195,7 @@ private enum Verdict private final JsonNode raw; private List retainedPaths; + private List rejectedPaths; public static JsonSchema of( String schema) @@ -226,6 +232,7 @@ public static JsonSchema of( if (result != ANY && result != NONE) { result.retainedPaths = result.collectMatchingPaths((node, structured) -> !structured && !node.deny); + result.rejectedPaths = result.collectMatchingPaths((node, structured) -> !structured && node.deny); } return result; } @@ -304,6 +311,12 @@ public List retainedPaths() return retainedPaths != null ? retainedPaths : List.of(); } + @Override + public List rejectedPaths() + { + return rejectedPaths != null ? rejectedPaths : List.of(); + } + @Override public List matchingPaths( Predicate filter) @@ -343,6 +356,15 @@ private void collectPaths( { entry.getValue().collectPaths(pointer + "/" + escapePointer(entry.getKey()), visitor, pointers); } + // an object with named properties is otherwise closed to its declared keys (the same as when + // additionalProperties is absent entirely, preserving every existing schema's pruning + // behavior) -- only an explicit, non-false additionalProperties widens it, retaining a + // wildcard path alongside the named ones for whatever key doesn't match them + if (hasAdditional && additionalAllowed) + { + JsonSchemaImpl additional = additionalSchema != null ? additionalSchema : ANY; + additional.collectPaths(pointer + "/" + WILDCARD_PROPERTY, visitor, pointers); + } } if (items != null) { diff --git a/runtime/common-json/src/test/java/io/aklivity/zilla/runtime/common/json/JsonProjectorTest.java b/runtime/common-json/src/test/java/io/aklivity/zilla/runtime/common/json/JsonProjectorTest.java index f1ba250a706..b25278da038 100644 --- a/runtime/common-json/src/test/java/io/aklivity/zilla/runtime/common/json/JsonProjectorTest.java +++ b/runtime/common-json/src/test/java/io/aklivity/zilla/runtime/common/json/JsonProjectorTest.java @@ -50,6 +50,33 @@ void shouldRetainArrayWildcardField() "{\"items\":[{\"id\":1,\"x\":9},{\"id\":2,\"y\":8}],\"k\":0}")); } + @Test + void shouldRetainPropertyWildcardField() + { + assertEquals("{\"a\":1,\"x\":2,\"y\":3}", + project(List.of("/a", "/*"), "{\"a\":1,\"x\":2,\"y\":3}")); + } + + @Test + void shouldPreferNamedPropertyOverWildcard() + { + // the wildcard is a fallback, not an override -- a key with its own retained pointer keeps that + // pointer's shape even when it would also match the sibling wildcard + assertEquals("{\"a\":{\"b\":1},\"c\":{\"d\":2,\"e\":3}}", + project(List.of("/a/b", "/*"), "{\"a\":{\"b\":1,\"z\":9},\"c\":{\"d\":2,\"e\":3}}")); + } + + @Test + void shouldRetainPropertyWildcardFieldThatFragmentsAcrossInputWindows() + { + // the unmatched key is longer than every named sibling (and the feed window), so onKey's + // exceeds-max-named-length shortcut must not declare SKIP early now that a wildcard sibling means + // a longer key can still match + String key = "x".repeat(40); + assertEquals("{\"a\":1,\"" + key + "\":2}", + projectWindowed(List.of("/a", "/*"), "{\"a\":1,\"" + key + "\":2}", 8)); + } + @Test void shouldRetainExplicitArrayIndex() { diff --git a/runtime/common-json/src/test/java/io/aklivity/zilla/runtime/common/json/JsonSchemaRetainedPathsTest.java b/runtime/common-json/src/test/java/io/aklivity/zilla/runtime/common/json/JsonSchemaRetainedPathsTest.java index d8efc854b4d..8ad1eac9c69 100644 --- a/runtime/common-json/src/test/java/io/aklivity/zilla/runtime/common/json/JsonSchemaRetainedPathsTest.java +++ b/runtime/common-json/src/test/java/io/aklivity/zilla/runtime/common/json/JsonSchemaRetainedPathsTest.java @@ -48,6 +48,56 @@ void shouldCollectArrayItemsWildcard() "{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"integer\"}}}}}}")); } + @Test + void shouldCollectWildcardWhenAdditionalPropertiesAllowed() + { + assertEquals(List.of("/a", "/*"), + retained("{\"properties\":{\"a\":{\"type\":\"integer\"}},\"additionalProperties\":true}")); + } + + @Test + void shouldCollectWildcardForTypedAdditionalPropertiesSchema() + { + assertEquals(List.of("/a", "/*"), + retained("{\"properties\":{\"a\":{\"type\":\"integer\"}},\"additionalProperties\":{\"type\":\"string\"}}")); + } + + @Test + void shouldNotCollectWildcardWhenAdditionalPropertiesAbsent() + { + assertEquals(List.of("/a"), + retained("{\"properties\":{\"a\":{\"type\":\"integer\"}}}")); + } + + @Test + void shouldNotCollectWildcardWhenAdditionalPropertiesFalse() + { + assertEquals(List.of("/a"), + retained("{\"properties\":{\"a\":{\"type\":\"integer\"}},\"additionalProperties\":false}")); + } + + @Test + void shouldCollectRejectedPathForFalseProperty() + { + assertEquals(List.of("/b"), rejected("{\"properties\":{\"a\":true,\"b\":false}}")); + } + + @Test + void shouldCollectRejectedPathAlongsideAdditionalPropertiesWildcard() + { + JsonSchema schema = JsonSchema.of( + "{\"type\":\"object\",\"properties\":{\"connector.class\":{\"type\":\"string\"}," + + "\"connector\":false},\"additionalProperties\":true}"); + assertEquals(List.of("/connector.class", "/*"), schema.retainedPaths()); + assertEquals(List.of("/connector"), schema.rejectedPaths()); + } + + @Test + void shouldHaveNoRejectedPathsWhenNoPropertyIsDenied() + { + assertEquals(List.of(), rejected("{\"properties\":{\"a\":{\"type\":\"integer\"}}}")); + } + @Test void shouldTreatStructurelessObjectAsRetainedLeaf() { @@ -120,9 +170,61 @@ void shouldDriveProjectorEndToEnd() assertEquals("{\"items\":[{\"id\":1},{\"id\":2}]} ", new String(out, UTF_8)); } + @Test + void shouldDriveProjectorEndToEndWithAdditionalProperties() + { + JsonGeneratorEx gen = JsonEx.createGenerator(); + MutableDirectBufferEx buffer = new UnsafeBufferEx(new byte[1024]); + gen.wrap(buffer, 0, buffer.capacity()); + JsonSchema schema = JsonSchema.of( + "{\"type\":\"object\",\"properties\":{\"connector.class\":{\"type\":\"string\"}}," + + "\"additionalProperties\":true}"); + JsonPipeline pipeline = JsonEx.stream(JsonEx.createParser()) + .transform(JsonTransforms.projector(schema.retainedPaths())) + .into(JsonEx.createSink(gen)); + pipeline.reset(); + byte[] bytes = "{\"connector.class\":\"FileStreamSource\",\"file\":\"/tmp/x\",\"topic\":\"t\"} " + .getBytes(UTF_8); + pipeline.transform(new UnsafeBufferEx(bytes), 0, bytes.length); + byte[] out = new byte[gen.length()]; + buffer.getBytes(0, out); + assertEquals("{\"connector.class\":\"FileStreamSource\",\"file\":\"/tmp/x\",\"topic\":\"t\"} ", + new String(out, UTF_8)); + } + + @Test + void shouldDriveProjectorEndToEndRejectingNamedPropertyOverWildcard() + { + // "connector" shares the same object as connector.class and the open-ended config fields (the + // shape of a tools/call arguments object carrying both a path parameter and a generic body), and + // must stay excluded even though additionalProperties would otherwise keep it via the wildcard + JsonGeneratorEx gen = JsonEx.createGenerator(); + MutableDirectBufferEx buffer = new UnsafeBufferEx(new byte[1024]); + gen.wrap(buffer, 0, buffer.capacity()); + JsonSchema schema = JsonSchema.of( + "{\"type\":\"object\",\"properties\":{\"connector.class\":{\"type\":\"string\"}," + + "\"connector\":false},\"additionalProperties\":true}"); + JsonPipeline pipeline = JsonEx.stream(JsonEx.createParser()) + .transform(JsonTransforms.projector(schema)) + .into(JsonEx.createSink(gen)); + pipeline.reset(); + byte[] bytes = ("{\"connector\":\"connector1\",\"connector.class\":\"FileStreamSource\"," + + "\"topic\":\"t\"} ").getBytes(UTF_8); + pipeline.transform(new UnsafeBufferEx(bytes), 0, bytes.length); + byte[] out = new byte[gen.length()]; + buffer.getBytes(0, out); + assertEquals("{\"connector.class\":\"FileStreamSource\",\"topic\":\"t\"} ", new String(out, UTF_8)); + } + private static List retained( String schema) { return JsonSchema.of(schema).retainedPaths(); } + + private static List rejected( + String schema) + { + return JsonSchema.of(schema).rejectedPaths(); + } } diff --git a/runtime/common-openapi/src/main/java/io/aklivity/zilla/runtime/common/openapi/model/OpenapiSchema.java b/runtime/common-openapi/src/main/java/io/aklivity/zilla/runtime/common/openapi/model/OpenapiSchema.java index 641716cc599..a9088d2e208 100644 --- a/runtime/common-openapi/src/main/java/io/aklivity/zilla/runtime/common/openapi/model/OpenapiSchema.java +++ b/runtime/common-openapi/src/main/java/io/aklivity/zilla/runtime/common/openapi/model/OpenapiSchema.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.Map; +import jakarta.json.JsonValue; import jakarta.json.bind.annotation.JsonbProperty; public class OpenapiSchema extends AbstractOpenapiResolvable @@ -25,6 +26,11 @@ public class OpenapiSchema extends AbstractOpenapiResolvable public OpenapiSchema items; public Map properties; public List required; + // captured as a raw JsonValue (rather than resolved/recursively-bound like items/properties/schema) + // since a boolean literal (true/false) is a valid alternative to a nested schema object here, and + // consumers that shape output from this schema (e.g. common-json's JsonSchemaImpl) already understand + // both forms when parsing the serialized schema text + public JsonValue additionalProperties; public String format; public String description; @JsonbProperty("enum") diff --git a/runtime/common-openapi/src/main/java/io/aklivity/zilla/runtime/common/openapi/view/OpenapiSchemaView.java b/runtime/common-openapi/src/main/java/io/aklivity/zilla/runtime/common/openapi/view/OpenapiSchemaView.java index 572e4b3aa3e..0ef82686f81 100644 --- a/runtime/common-openapi/src/main/java/io/aklivity/zilla/runtime/common/openapi/view/OpenapiSchemaView.java +++ b/runtime/common-openapi/src/main/java/io/aklivity/zilla/runtime/common/openapi/view/OpenapiSchemaView.java @@ -21,6 +21,7 @@ import java.util.Optional; import java.util.stream.Collectors; +import jakarta.json.JsonValue; import jakarta.json.bind.annotation.JsonbProperty; import jakarta.json.bind.annotation.JsonbPropertyOrder; @@ -117,6 +118,7 @@ public Optional extension( "type", "items", "properties", + "additionalProperties", "required", "format", "description", @@ -146,6 +148,7 @@ public static final class OpenapiJsonSchema public String type; public OpenapiSchema items; public Map properties; + public JsonValue additionalProperties; public List required; public String format; public String description; @@ -181,6 +184,7 @@ public static OpenapiJsonSchema of( ? model.properties.entrySet().stream() .collect(Collectors.toMap(Map.Entry::getKey, e -> of(e.getValue()))) : null; + json.additionalProperties = model.additionalProperties; json.required = model.required; json.format = model.format; json.description = model.description; diff --git a/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/http/update.connector.config/client.rpt b/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/http/update.connector.config/client.rpt index 90e4f9f495e..5ea9667bd63 100644 --- a/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/http/update.connector.config/client.rpt +++ b/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/http/update.connector.config/client.rpt @@ -28,7 +28,7 @@ write zilla:begin.ext ${http:beginEx() connected -write '{"connector.class":"FileStreamSource","tasks.max":"2"}' +write '{"connector.class":"FileStreamSource","tasks.max":"2","file":"/tmp/kc-source.txt","topic":"connect-demo"}' read zilla:begin.ext ${http:matchBeginEx() .typeId(zilla:id("http")) @@ -36,7 +36,7 @@ read zilla:begin.ext ${http:matchBeginEx() .header("content-type", "application/json") .build()} -read '{"name":"connector1","config":{"connector.class":"FileStreamSource","tasks.max":"2"},"tasks":[],"type":"source"}' +read '{"name":"connector1","config":{"connector.class":"FileStreamSource","tasks.max":"2","file":"/tmp/kc-source.txt","topic":"connect-demo"},"tasks":[],"type":"source"}' read closed write close diff --git a/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/http/update.connector.config/server.rpt b/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/http/update.connector.config/server.rpt index 7ec05119140..4c1ebf3f9ee 100644 --- a/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/http/update.connector.config/server.rpt +++ b/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/http/update.connector.config/server.rpt @@ -32,7 +32,7 @@ read zilla:begin.ext ${http:matchBeginEx() connected -read '{"connector.class":"FileStreamSource","tasks.max":"2"}' +read '{"connector.class":"FileStreamSource","tasks.max":"2","file":"/tmp/kc-source.txt","topic":"connect-demo"}' write zilla:begin.ext ${http:beginEx() .typeId(zilla:id("http")) @@ -41,7 +41,7 @@ write zilla:begin.ext ${http:beginEx() .build()} write flush -write '{"name":"connector1","config":{"connector.class":"FileStreamSource","tasks.max":"2"},"tasks":[],"type":"source"}' +write '{"name":"connector1","config":{"connector.class":"FileStreamSource","tasks.max":"2","file":"/tmp/kc-source.txt","topic":"connect-demo"},"tasks":[],"type":"source"}' write flush write close diff --git a/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/http/validate.connector.config/client.rpt b/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/http/validate.connector.config/client.rpt index b8d4294ee8a..0ef087e8fee 100644 --- a/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/http/validate.connector.config/client.rpt +++ b/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/http/validate.connector.config/client.rpt @@ -28,7 +28,7 @@ write zilla:begin.ext ${http:beginEx() connected -write '{"connector.class":"FileStreamSource","tasks.max":"2"}' +write '{"connector.class":"FileStreamSource","tasks.max":"2","file":"/tmp/kc-source.txt","topic":"connect-demo"}' read zilla:begin.ext ${http:matchBeginEx() .typeId(zilla:id("http")) diff --git a/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/http/validate.connector.config/server.rpt b/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/http/validate.connector.config/server.rpt index 2ef511692b2..1e7ed5836c6 100644 --- a/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/http/validate.connector.config/server.rpt +++ b/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/http/validate.connector.config/server.rpt @@ -32,7 +32,7 @@ read zilla:begin.ext ${http:matchBeginEx() connected -read '{"connector.class":"FileStreamSource","tasks.max":"2"}' +read '{"connector.class":"FileStreamSource","tasks.max":"2","file":"/tmp/kc-source.txt","topic":"connect-demo"}' write zilla:begin.ext ${http:beginEx() .typeId(zilla:id("http")) diff --git a/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/mcp/update.connector.config/client.rpt b/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/mcp/update.connector.config/client.rpt index b3515a15c64..f32ddd8fcf3 100644 --- a/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/mcp/update.connector.config/client.rpt +++ b/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/mcp/update.connector.config/client.rpt @@ -44,15 +44,15 @@ write zilla:begin.ext ${mcp:beginEx() .toolsCall() .sessionId("5ca1ab1e-c0de-4a11-5e55-000100000000") .name("update_connector_config") - .contentLength(126) + .contentLength(177) .build() .build()} connected -write '{"name":"update_connector_config","arguments":{"connector":"connector1","connector.class":"FileStreamSource","tasks.max":"2"}}' +write '{"name":"update_connector_config","arguments":{"connector":"connector1","connector.class":"FileStreamSource","tasks.max":"2","file":"/tmp/kc-source.txt","topic":"connect-demo"}}' -read '{"structuredContent":{"name":"connector1","config":{"connector.class":"FileStreamSource","tasks.max":"2"},"tasks":[],"type":"source"},"content":[{"type":"text","text":"Updated connector config for connector1"}],"isError":false}' +read '{"structuredContent":{"name":"connector1","config":{"connector.class":"FileStreamSource","tasks.max":"2","file":"/tmp/kc-source.txt","topic":"connect-demo"},"tasks":[],"type":"source"},"content":[{"type":"text","text":"Updated connector config for connector1"}],"isError":false}' read closed write close diff --git a/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/mcp/update.connector.config/server.rpt b/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/mcp/update.connector.config/server.rpt index bcdd7f2a62e..df92dabd8ba 100644 --- a/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/mcp/update.connector.config/server.rpt +++ b/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/mcp/update.connector.config/server.rpt @@ -44,17 +44,17 @@ read zilla:begin.ext ${mcp:matchBeginEx() .toolsCall() .sessionId("5ca1ab1e-c0de-4a11-5e55-000100000000") .name("update_connector_config") - .contentLength(126) + .contentLength(177) .build() .build()} connected -read '{"name":"update_connector_config","arguments":{"connector":"connector1","connector.class":"FileStreamSource","tasks.max":"2"}}' +read '{"name":"update_connector_config","arguments":{"connector":"connector1","connector.class":"FileStreamSource","tasks.max":"2","file":"/tmp/kc-source.txt","topic":"connect-demo"}}' write flush -write '{"structuredContent":{"name":"connector1","config":{"connector.class":"FileStreamSource","tasks.max":"2"},"tasks":[],"type":"source"},"content":[{"type":"text","text":"Updated connector config for connector1"}],"isError":false}' +write '{"structuredContent":{"name":"connector1","config":{"connector.class":"FileStreamSource","tasks.max":"2","file":"/tmp/kc-source.txt","topic":"connect-demo"},"tasks":[],"type":"source"},"content":[{"type":"text","text":"Updated connector config for connector1"}],"isError":false}' write flush write close diff --git a/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/mcp/validate.connector.config/client.rpt b/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/mcp/validate.connector.config/client.rpt index 923c343a25c..6a5d16aa09d 100644 --- a/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/mcp/validate.connector.config/client.rpt +++ b/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/mcp/validate.connector.config/client.rpt @@ -44,13 +44,13 @@ write zilla:begin.ext ${mcp:beginEx() .toolsCall() .sessionId("5ca1ab1e-c0de-4a11-5e55-000100000000") .name("validate_connector_config") - .contentLength(135) + .contentLength(186) .build() .build()} connected -write '{"name":"validate_connector_config","arguments":{"pluginName":"FileStreamSource","connector.class":"FileStreamSource","tasks.max":"2"}}' +write '{"name":"validate_connector_config","arguments":{"pluginName":"FileStreamSource","connector.class":"FileStreamSource","tasks.max":"2","file":"/tmp/kc-source.txt","topic":"connect-demo"}}' read '{"structuredContent":{"name":"FileStreamSource","error_count":0,"groups":[],"configs":[]},"content":[{"type":"text","text":"Validated connector config with 0 errors"}],"isError":false}' read closed diff --git a/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/mcp/validate.connector.config/server.rpt b/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/mcp/validate.connector.config/server.rpt index 20c6109c7ee..ab29fc5d595 100644 --- a/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/mcp/validate.connector.config/server.rpt +++ b/specs/binding-mcp-kafka-connect.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/kafka/connect/streams/mcp/validate.connector.config/server.rpt @@ -44,13 +44,13 @@ read zilla:begin.ext ${mcp:matchBeginEx() .toolsCall() .sessionId("5ca1ab1e-c0de-4a11-5e55-000100000000") .name("validate_connector_config") - .contentLength(135) + .contentLength(186) .build() .build()} connected -read '{"name":"validate_connector_config","arguments":{"pluginName":"FileStreamSource","connector.class":"FileStreamSource","tasks.max":"2"}}' +read '{"name":"validate_connector_config","arguments":{"pluginName":"FileStreamSource","connector.class":"FileStreamSource","tasks.max":"2","file":"/tmp/kc-source.txt","topic":"connect-demo"}}' write flush