This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-25194 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 42883d3c11fc29628a969d5d22d9df2feef57c19 Author: Claus Ibsen <[email protected]> AuthorDate: Thu Oct 1 18:08:57 2026 +0200 CAMEL-25194: validate a Kamelet as a Kamelet, so the tools can write one A .kamelet.yaml is a YAML object, not the list of entries a route file is, so the route schema failed every one of them with "object found, array expected (a Camel YAML file is a list of entries, each starting with "- ")". That reached three surfaces: camel validate yaml, camel_validate_source, and YamlLoadFailureReport. And because camel_write_file and camel_edit_file validate before they write, a custom Kamelet could not be written through the tools at all -- an agent on the supported path had to go around them. It also misdirected when the error was real. A jq step written as a step of its own fails in the loader at the step, and the report printed the array hint at the envelope instead, pointing away from the fault. The validator now compiles a second schema for a Kamelet, built from the route schema so that one remains the source of truth: the items node of the route schema is kept at /items so the #/items/definitions/... references inside it still resolve, and spec.template points at it, because the template of a Kamelet is one entry of a route file, which is what KameletRoutesBuilderLoader makes of it. A document with kind: Kamelet is checked against that, everything else against the route schema as before, and the hints and the extra checks that follow are untouched: an error in the template comes out as Line 12: /spec/template/from/steps/0: property 'jq' is not defined in the schema and the schema does not allow additional properties (did you mean 'to'?) The envelope is described loosely on purpose. Camel does not own the Kamelet CRD, so annotations and spec keys it does not read are accepted as they come rather than failed for being unknown. BodyTypeFlow already returns for a root that is not an array, so the body-type checks do not run over a Kamelet; that is worth doing later. Verified on the file of the issue, which camel run executes: camel validate yaml says "Validation success", and camel_write_file writes it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Bp3538HRBPMQkb5ta9xRaj --- .../camel/dsl/yaml/validator/YamlValidator.java | 58 +++++++++- .../dsl/yaml/validator/KameletValidationTest.java | 125 +++++++++++++++++++++ 2 files changed, 182 insertions(+), 1 deletion(-) diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java index ed05a0318289..b3931e870633 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java @@ -35,6 +35,7 @@ import java.util.regex.Pattern; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.networknt.schema.Error; import com.networknt.schema.Schema; @@ -87,6 +88,13 @@ public class YamlValidator { private final String schemaJson; private CamelCatalog catalog; private Schema schema; + /** + * The schema for a Kamelet: the envelope, with its {@code spec.template} being one entry of the route schema. A + * {@code .kamelet.yaml} is a YAML object and not the list of entries a route file is, so the route schema rejects + * every one of them as "object found, array expected" -- and because the tools validate before they write, the file + * cannot be written at all (CAMEL-25194). + */ + private Schema kameletSchema; private Map<String, OneOfGroup> oneOfGroups; private record OneOfGroup(Set<String> alternatives, boolean required) { @@ -585,8 +593,55 @@ public class YamlValidator { return null; } + /** + * A schema document for a Kamelet, built from the route schema so that one is the source of truth for both. The + * {@code items} node of the route schema is kept at {@code /items} so that the {@code #/items/definitions/...} + * references inside it still resolve, and {@code spec.template} points at it: the template of a Kamelet is one + * entry of a route file, which is what the loader makes of it. + * <p/> + * The envelope is described loosely on purpose. Camel does not own the Kamelet CRD, so the parts it does not read + * are accepted as they come rather than failed for being unknown. + */ + private JsonNode kameletModel(JsonNode routeModel) { + ObjectNode root = mapper.createObjectNode(); + JsonNode dialect = routeModel.get("$schema"); + if (dialect != null) { + root.set("$schema", dialect); + } + root.put("type", "object"); + root.put("additionalProperties", true); + ObjectNode properties = root.putObject("properties"); + properties.putObject("apiVersion").put("type", "string"); + properties.putObject("kind").put("type", "string"); + properties.putObject("metadata").put("type", "object"); + ObjectNode spec = properties.putObject("spec"); + spec.put("type", "object"); + spec.put("additionalProperties", true); + ObjectNode specProperties = spec.putObject("properties"); + specProperties.putObject("definition").put("type", "object"); + specProperties.putObject("types").put("type", "object"); + specProperties.putObject("dataTypes").put("type", "object"); + specProperties.putObject("dependencies").put("type", "array"); + specProperties.putObject("template").put("$ref", "#/items"); + root.set("items", routeModel.get("items")); + return root; + } + + /** + * Whether the document is a Kamelet rather than a list of route entries: {@code kind: Kamelet}, which is what the + * loader looks at too. + */ + static boolean isKamelet(JsonNode target) { + if (target == null || !target.isObject()) { + return false; + } + JsonNode kind = target.get("kind"); + return kind != null && kind.isTextual() && "Kamelet".equals(kind.asText()); + } + private List<Error> validate(JsonNode target, Set<String> bodylessEndpoints) { - var errors = filterOneOfNoise(new ArrayList<>(schema.validate(target))); + Schema against = isKamelet(target) ? kameletSchema : schema; + var errors = filterOneOfNoise(new ArrayList<>(against.validate(target))); errors.removeIf(YamlValidator::isRuntimeAcceptedScalar); if (canonical) { errors = SchemaHints.apply(SchemaHints.COMPACT, errors, this); @@ -1380,6 +1435,7 @@ public class YamlValidator { // Use a proper URI for the schema location to ensure $ref resolution works var schemaLocation = SchemaLocation.of(location); schema = schemaRegistry.getSchema(schemaLocation, model); + kameletSchema = schemaRegistry.getSchema(SchemaLocation.of(location + "-kamelet"), kameletModel(model)); if (canonical) { oneOfGroups = loadOneOfGroups(); diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/KameletValidationTest.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/KameletValidationTest.java new file mode 100644 index 000000000000..1d2a2235953d --- /dev/null +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/KameletValidationTest.java @@ -0,0 +1,125 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.yaml.validator; + +import java.util.List; + +import com.networknt.schema.Error; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * CAMEL-25194: a .kamelet.yaml is a YAML object and not the list of entries a route file is, so the route schema failed + * every one of them as "object found, array expected" -- and because the tools validate before they write, the file + * could not be written at all. + */ +class KameletValidationTest { + + private static final String WORKING = """ + apiVersion: camel.apache.org/v1 + kind: Kamelet + metadata: + name: content-filter-action + labels: + camel.apache.org/kamelet.type: action + spec: + definition: + title: Content Filter + properties: + allowlist: + title: Allowlist + type: string + template: + from: + uri: kamelet:source + steps: + - setBody: + expression: + jq: + expression: 'with_entries(select(.key as $k | "{{allowlist}}" | split(",") | index($k)))' + """; + + private static List<String> validate(String content) throws Exception { + YamlValidator v = new YamlValidator(); + List<Error> errors = v.validate(content); + return YamlValidator.describeAll(content, errors); + } + + @Test + void aKameletThatRunsIsValid() throws Exception { + // this is the file of the issue: camel run logs the filtered body for it + assertThat(validate(WORKING)).isEmpty(); + } + + @Test + void aMistakeInTheTemplateIsReportedInTheTemplate() throws Exception { + // jq is a language, not a step of its own: the error belongs to the step, not to the envelope + String broken = """ + apiVersion: camel.apache.org/v1 + kind: Kamelet + metadata: + name: content-filter-action + spec: + definition: + title: Content Filter + template: + from: + uri: kamelet:source + steps: + - jq: + expression: keep + """; + List<String> errors = validate(broken); + assertThat(errors).hasSize(1); + assertThat(errors.get(0)) + .contains("Line 12") + .contains("/spec/template/from/steps/0") + .contains("property 'jq' is not defined in the schema") + .doesNotContain("array expected"); + } + + @Test + void aRouteFileIsStillValidatedAsAListOfEntries() throws Exception { + assertThat(validate(""" + - route: + id: ok + from: + uri: timer:tick + steps: + - log: + message: "hi" + """)).isEmpty(); + assertThat(validate(""" + route: + id: notAList + """)).anyMatch(e -> e.contains("array expected")); + } + + @Test + void theEnvelopeIsNotFailedForWhatCamelDoesNotRead() throws Exception { + // Camel does not own the Kamelet CRD; annotations and extra spec keys are accepted as they come + assertThat(validate(WORKING.replace(""" + metadata: + name: content-filter-action""", + """ + metadata: + name: content-filter-action + annotations: + camel.apache.org/catalog.version: "4.23.0\""""))).isEmpty(); + } +}
