This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 71fa9841b0d8 CAMEL-25194: validate a Kamelet as a Kamelet, so the 
tools can write one (#27227)
71fa9841b0d8 is described below

commit 71fa9841b0d826767f8de2c69a2cb01eadce720e
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 1 19:51:48 2026 +0200

    CAMEL-25194: validate a Kamelet as a Kamelet, so the tools can write one 
(#27227)
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---
 .../camel/dsl/yaml/validator/YamlValidator.java    | 132 ++++++++++++--
 .../dsl/yaml/validator/KameletValidationTest.java  | 193 +++++++++++++++++++++
 2 files changed, 315 insertions(+), 10 deletions(-)

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..688fce7f3234 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,9 +593,104 @@ 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", "#/templateEntry");
+        // an entry of a route file is exactly one of route/from/beans/..., 
which is what maxProperties says. The
+        // template of a Kamelet is not: counter-source declares its bean 
beside its from, and 60 of the 250 kamelets
+        // of the library do the same. Everything else about an entry still 
holds, and the $refs inside the copy point
+        // at #/items/definitions/..., which is why the items node is kept as 
well.
+        ObjectNode template = routeModel.get("items").deepCopy();
+        template.remove("maxProperties");
+        root.set("templateEntry", template);
+        root.set("items", routeModel.get("items"));
+        return root;
+    }
+
+    /**
+     * The Kamelet without the parts that are not Camel: {@code 
spec.definition} is the JSON schema of its properties,
+     * and the type declarations are schemas too. The checks that walk the 
document for Camel nodes would otherwise read
+     * a property of that schema as a step of the same name. Keys are only 
removed, so every path under
+     * {@code spec.template} is unchanged and the reported lines stay right.
+     */
+    private static JsonNode withoutKameletMetadata(JsonNode target) {
+        ObjectNode copy = ((ObjectNode) target).deepCopy();
+        JsonNode spec = copy.get("spec");
+        if (spec instanceof ObjectNode specNode) {
+            specNode.remove("definition");
+            specNode.remove("types");
+            specNode.remove("dataTypes");
+        }
+        return copy;
+    }
+
+    /**
+     * 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)));
+        // the checks below walk the document looking for Camel nodes, and 
spec.definition of a Kamelet is a JSON
+        // schema of its properties, not Camel: a property named delay is not 
the Delay EIP, which is what
+        // aws-s3-source and eight others were told. Removing the branch 
leaves every path under spec.template as it
+        // was, so the locations and the lines stay right.
+        JsonNode camelOnly = isKamelet(target) ? 
withoutKameletMetadata(target) : target;
+        // the locations the runtime accepts after all, before they are 
dropped: a branch of an anyOf that failed only
+        // because of one of them leaves its companions behind, and those say 
the file needs a property it does not
+        // (fhir-sink unmarshals with fhirXml, whose fhirVersion is a 
placeholder, and was told fhirJson was required)
+        List<String> accepted = new ArrayList<>();
+        for (Error e : errors) {
+            if (isRuntimeAcceptedScalar(e)) {
+                accepted.add(String.valueOf(e.getInstanceLocation()));
+            }
+        }
         errors.removeIf(YamlValidator::isRuntimeAcceptedScalar);
+        if (!accepted.isEmpty()) {
+            errors.removeIf(e -> {
+                String keyword = e.getKeyword();
+                if (!"required".equals(keyword) && !"oneOf".equals(keyword) && 
!"anyOf".equals(keyword)) {
+                    return false;
+                }
+                String at = String.valueOf(e.getInstanceLocation());
+                return accepted.stream().anyMatch(a -> a.startsWith(at + "/"));
+            });
+        }
         if (canonical) {
             errors = SchemaHints.apply(SchemaHints.COMPACT, errors, this);
         }
@@ -599,7 +702,7 @@ public class YamlValidator {
         // plus one "required property <language> not found" per language; 
replace that with one line that says
         // what to write, at the node's own location
         List<Error> missing = new ArrayList<>();
-        checkRequiredExpressions(target, new NodePath(PathType.JSON_POINTER), 
missing);
+        checkRequiredExpressions(camelOnly, new 
NodePath(PathType.JSON_POINTER), missing);
         for (Error m : missing) {
             String at = String.valueOf(m.getInstanceLocation());
             errors.removeIf(e -> 
String.valueOf(e.getInstanceLocation()).equals(at)
@@ -619,13 +722,13 @@ public class YamlValidator {
                     && ("oneOf".equals(e.getKeyword()) || 
"required".equals(e.getKeyword())));
         }
         if (errors.isEmpty()) {
-            checkSimpleSyntaxInScripts(target, new 
NodePath(PathType.JSON_POINTER), errors);
-            checkDynamicUri(target, new NodePath(PathType.JSON_POINTER), 
errors);
+            checkSimpleSyntaxInScripts(camelOnly, new 
NodePath(PathType.JSON_POINTER), errors);
+            checkDynamicUri(camelOnly, new NodePath(PathType.JSON_POINTER), 
errors);
             // where the body comes from, across the routes of the file 
(CAMEL-24844)
-            BodyTypeFlow.check(target, new NodePath(PathType.JSON_POINTER), 
errors, bodylessEndpoints);
+            BodyTypeFlow.check(camelOnly, new NodePath(PathType.JSON_POINTER), 
errors, bodylessEndpoints);
         }
         if (canonical) {
-            checkOneOfCardinality(target, new NodePath(PathType.JSON_POINTER), 
errors);
+            checkOneOfCardinality(camelOnly, new 
NodePath(PathType.JSON_POINTER), errors);
             // unmarshal: {jackson: {}}: the unknown key already got its hint; 
the list of every data format that
             // "found none" adds at the same location only buries it
             errors.removeIf(e -> "oneOf".equals(e.getKeyword())
@@ -1129,18 +1232,26 @@ public class YamlValidator {
      * CAMEL-24696 before exposing it.
      */
     static boolean isRuntimeAcceptedScalar(Error error) {
-        if (!"type".equals(error.getKeyword())) {
+        String keyword = error.getKeyword();
+        if (!"type".equals(keyword) && !"enum".equals(keyword)) {
             return false;
         }
         JsonNode instance = error.getInstanceNode();
         if (instance == null) {
             return false;
         }
+        if (instance.isTextual() && hasPropertyPlaceholder(instance.asText())) 
{
+            // the runtime resolves the placeholder before it looks at the 
value, so neither its type nor its
+            // membership of an enumeration can be told from here. A Kamelet 
template is made of them:
+            // fhir-source has fhirVersion: "{{fhirVersion}}" where the schema 
lists the six versions
+            return true;
+        }
+        if (!"type".equals(keyword)) {
+            // what follows is the leniency of a converted scalar, which is 
about types only
+            return false;
+        }
         if (instance.isTextual()) {
             String text = instance.asText();
-            if (hasPropertyPlaceholder(text)) {
-                return true;
-            }
             if (isExpectedType(error, "boolean")) {
                 return isBooleanText(text);
             }
@@ -1380,6 +1491,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..0b553fd1b423
--- /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,193 @@
+/*
+ * 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 aTemplateMayDeclareBeansBesideItsFrom() throws Exception {
+        // an entry of a route file is exactly one of route/from/beans/...; a 
Kamelet template is not, and 60 of the
+        // 250 kamelets of the library declare a bean beside their from, 
counter-source among them
+        assertThat(validate("""
+                apiVersion: camel.apache.org/v1
+                kind: Kamelet
+                metadata:
+                  name: counter-source
+                spec:
+                  template:
+                    beans:
+                      - name: counter
+                        type: java.util.concurrent.atomic.AtomicInteger
+                    from:
+                      uri: timer:counter
+                      steps:
+                        - bean:
+                            ref: "{{counter}}"
+                            method: getAndIncrement
+                """)).isEmpty();
+    }
+
+    @Test
+    void aPropertyOfTheDefinitionIsNotAStepOfTheSameName() throws Exception {
+        // spec.definition is the JSON schema of the Kamelet's properties: a 
property named delay is not the Delay EIP,
+        // which is what aws-s3-source and eight others of the library were 
told
+        assertThat(validate("""
+                apiVersion: camel.apache.org/v1
+                kind: Kamelet
+                metadata:
+                  name: aws-s3-source
+                spec:
+                  definition:
+                    title: AWS S3 Source
+                    properties:
+                      delay:
+                        title: Delay
+                        type: integer
+                        default: 500
+                  template:
+                    from:
+                      uri: timer:tick
+                      steps:
+                        - to: "kamelet:sink"
+                """)).isEmpty();
+    }
+
+    @Test
+    void aPlaceholderWhereAnEnumerationIsExpectedIsAccepted() throws Exception 
{
+        // a Kamelet template is made of placeholders, and the runtime 
resolves one before it looks at the value:
+        // fhir-source has fhirVersion: "{{fhirVersion}}" where the schema 
lists the six versions
+        assertThat(validate("""
+                apiVersion: camel.apache.org/v1
+                kind: Kamelet
+                metadata:
+                  name: fhir-source
+                spec:
+                  template:
+                    from:
+                      uri: timer:tick
+                      steps:
+                        - marshal:
+                            fhirJson:
+                              fhirVersion: "{{fhirVersion}}"
+                """)).isEmpty();
+    }
+
+    @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();
+    }
+}

Reply via email to