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();
+ }
+}