You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/09/13 13:52:21 UTC
(camel) 01/01: CAMEL-24698: camel-yaml-dsl-validator - schema errors say what to write: step, list, property, expression and script hints, required expressions, the file's shape
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch pr/CAMEL-24698-validator-hints
in repository https://gitbox.apache.org/repos/asf/camel.git
commit 79e2afb741cde7e96cc05cd173868c987c01037d
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Sun Sep 13 15:09:59 2026 +0200
CAMEL-24698: camel-yaml-dsl-validator - schema errors say what to write: step, list, property, expression and script hints, required expressions, the file's shape
Squash of the 29 commits on fix/CAMEL-24698-validator-hints (the run-by-run history is on bench/after-run).
camel-yaml-dsl-validator - say what was probably meant when a property is not defined
camel-yaml-dsl - a route file with a second YAML document is reported by the validator and named by the loader
camel-yaml-dsl-validator - a map where a list is expected says how to write the list, an empty file says what a file is
camel-yaml-dsl-validator - a bean item keyed by its name says to write name: as a property
camel-yaml-dsl-validator - a //DEPS line at the top of a YAML file says to write it as a comment
camel-yaml-dsl-validator - validate(File) applies the same checks as validate(String), so camel validate yaml reports the //DEPS and empty-file hints too
camel-yaml-dsl-validator - text after the routes is named as such instead of 'while scanning a simple key'
restore the generated YAML DSL schema rewritten by a partial build in the worktree
camel-yaml-dsl-validator - language written as a property of an expression says to use it as the key
camel-yaml-dsl-validator - a bean written with id: or class: says the properties are name and type
camel-yaml-dsl-validator - prose after a list item is named by its own line, not the list item's line
camel-yaml-dsl-validator - propertyName, headerName, variableName suggest name
camel-yaml-dsl-validator - an expression node without an expression is reported
camel-yaml-dsl-validator - a step with two keys says a step is one EIP
camel-yaml-dsl-validator - bean as a language says method:, a header name as the key says name:, bean parameters say the method call
camel-yaml-dsl-validator - otherwise, when, doTry, doCatch written without steps: say where the EIPs go
camel-yaml-dsl-validator - steps: at the route level says it goes under from:
camel-yaml-dsl-validator - a second key at the top level says the lines belong indented under the entry
camel-yaml-dsl-validator - a log component option on the log EIP says to use a to: log: step
camel-yaml-dsl-validator - an exception class used as a key under onException says it is a list item under exception:
camel-yaml-dsl-validator - log: {level: INFO} suggests loggingLevel, not the log component
camel-yaml-dsl-validator - simple syntax inside a groovy (or other language) expression is named
camel-yaml-dsl-validator - a top-level entry written as a list (onException: [...]) says it is a map
camel-yaml-dsl-validator - with the expression required in the schema (CAMEL-24707), the missing-expression message replaces the schema's oneOf noise
camel-yaml-dsl-validator - a when item without a predicate gets the one-line message; sort is no longer reported (its expression is optional, CAMEL-24707)
camel-yaml-dsl-validator - an unknown property with a hint drops the strict schema's oneOf and required errors at the same node
camel-yaml-dsl-validator - resilience4j options written on circuitBreaker point to resilience4jConfiguration, name to id
camel-yaml-dsl-validator - a string property written as a language map (log message: {simple: ...}) says it is a plain string
camel-yaml-dsl-validator - a groovy GString is not simple syntax, script: as a language key names the language, a value continuing after its quote is named
Co-Authored-By: Claude Fable 5.1 <no...@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bp3538HRBPMQkb5ta9xRaj
---
.../camel/dsl/yaml/validator/YamlValidator.java | 712 ++++++++++++++++++++-
.../validator/YamlValidatorPropertyHintTest.java | 623 ++++++++++++++++++
.../validator/YamlValidatorScalarLeniencyTest.java | 3 +
.../camel/dsl/yaml/YamlRoutesBuilderLoader.java | 8 +-
4 files changed, 1335 insertions(+), 11 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 d5be9f63144d..32d9981c0f96 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
@@ -92,39 +92,393 @@ public class YamlValidator {
}
public List<Error> validate(File file) throws Exception {
- if (schema == null) {
- init();
- }
- try {
- var target = mapper.readTree(file);
- return validate(target);
- } catch (Exception e) {
- return List.of(parseError(e));
- }
+ // the same checks as for content, so the CLI and the tools report the same
+ return validate(java.nio.file.Files.readString(file.toPath()));
}
public List<Error> validate(String content) throws Exception {
if (schema == null) {
init();
}
+ Error extra = extraDocument(content);
+ if (extra != null) {
+ return List.of(extra);
+ }
+ Error deps = jbangDirective(content);
+ if (deps != null) {
+ return List.of(deps);
+ }
+ if (content == null || content.isBlank() || content.lines().allMatch(l -> l.isBlank() || l.trim().startsWith("#"))) {
+ return List.of(Error.builder().messageKey("empty").format(new MessageFormat("{0}"))
+ .arguments("the file has no YAML: a Camel YAML file is a list of entries, each starting with \"- \":"
+ + " - route:, - from:, - beans:, - rest:, - onException:")
+ .build());
+ }
try {
var target = mapper.readTree(content);
return validate(target);
} catch (Exception e) {
- return List.of(parseError(e));
+ return List.of(parseError(e, content));
}
}
+ private static final java.util.regex.Pattern LINE_COLUMN
+ = java.util.regex.Pattern.compile("line:? (\\d+), column:? (\\d+)");
+
+ /**
+ * A YAML parse error whose line is a line of text at column 1 after the routes (an explanation appended to the
+ * file, or a markdown fence) says so; the parser's "while scanning a simple key" does not.
+ */
+ static Error parseError(Exception e, String content) {
+ Error plain = parseError(e);
+ String msg = e.getMessage();
+ if (msg == null || content == null) {
+ return plain;
+ }
+ // the message names several positions (the collection being parsed, then the token that broke it); the
+ // problem is at the last one
+ String[] lines = content.split("\n", -1);
+ int line = -1;
+ String text = null;
+ java.util.regex.Matcher m = LINE_COLUMN.matcher(msg);
+ while (m.find()) {
+ int l = Integer.parseInt(m.group(1));
+ if (!"1".equals(m.group(2)) || l < 2 || l > lines.length) {
+ continue;
+ }
+ String t = lines[l - 1].trim();
+ if (t.isEmpty() || t.startsWith("-") || t.startsWith("#") || t.startsWith("%")) {
+ continue;
+ }
+ line = l;
+ text = t;
+ }
+ if (text == null) {
+ // a value that continues after its closing quote: message: ">>> " + exchange.getIn().getBody()
+ java.util.regex.Matcher any = LINE_COLUMN.matcher(msg);
+ int last = -1;
+ while (any.find()) {
+ last = Integer.parseInt(any.group(1));
+ }
+ if (last >= 1 && last <= lines.length) {
+ String t = lines[last - 1];
+ java.util.regex.Matcher q
+ = java.util.regex.Pattern.compile(":\\s*(\"(?:[^\"\\\\]|\\\\.)*\"|'[^']*')\\s*\\S").matcher(t);
+ if (q.find()) {
+ String key = t.trim().contains(":") ? t.trim().substring(0, t.trim().indexOf(':')) : "the value";
+ return Error.builder()
+ .messageKey("parser")
+ .format(new MessageFormat("{0}"))
+ .arguments("line " + last + ": the value of " + key + " continues after its closing quote"
+ + " (\"...\" + ...): a YAML value is one string, there is no concatenation; a"
+ + " log message is a simple expression, write it as one quoted text such as"
+ + " \">>> ${body}\"")
+ .build();
+ }
+ }
+ return plain;
+ }
+ String cleaned = msg.replace("\n", " ").replaceAll("\\s+", " ").trim();
+ int cut = cleaned.indexOf("in 'reader'");
+ String head = cut > 0 ? cleaned.substring(0, cut).trim() : cleaned;
+ return Error.builder()
+ .messageKey("parser")
+ .format(new MessageFormat("{0}"))
+ .arguments("line " + line + " is not YAML (\"" + (text.length() > 40 ? text.substring(0, 40) + "..." : text)
+ + "\"): a route file holds only the YAML, put explanations in a # comment or leave them out"
+ + " (" + head + ")")
+ .build();
+ }
+
+ /**
+ * {@code //DEPS org.apache.camel:camel-groovy} at the top of a YAML file: JBang's Java directive, which YAML reads
+ * as a plain string so the whole file becomes one scalar ("string found, array expected"). Name it, and say what a
+ * YAML file uses instead.
+ */
+ static Error jbangDirective(String content) {
+ if (content == null) {
+ return null;
+ }
+ String[] lines = content.split("\n", -1);
+ for (int i = 0; i < lines.length; i++) {
+ String t = lines[i].trim();
+ if (t.isEmpty() || t.startsWith("#")) {
+ continue;
+ }
+ if (t.startsWith("//DEPS") || t.startsWith("//JAVA") || t.startsWith("//SOURCES") || t.startsWith("//")) {
+ return Error.builder()
+ .messageKey("jbang")
+ .format(new MessageFormat("{0}"))
+ .arguments("line " + (i + 1) + ": " + t.split("\\s+")[0] + " is read as text by YAML, so the whole"
+ + " file becomes one string; write it as a YAML comment: # " + t
+ + " (camel-jbang reads //DEPS inside comments), or add the dependency with --dep or"
+ + " camel.jbang.dependencies in application.properties")
+ .build();
+ }
+ break;
+ }
+ return null;
+ }
+
+ /**
+ * A Camel YAML file is one YAML document. The YAML parser used here reads the first document and ignores what
+ * follows a {@code ---} separator (often an explanation the author appended), while the runtime rejects the file
+ * with "expected a single document in the stream"; so it is reported here, with the line of the separator.
+ */
+ static Error extraDocument(String content) {
+ if (content == null) {
+ return null;
+ }
+ String[] lines = content.split("\n", -1);
+ boolean seenContent = false;
+ for (int i = 0; i < lines.length; i++) {
+ String t = lines[i].trim();
+ if (t.equals("---") || t.equals("...")) {
+ if (seenContent) {
+ return Error.builder()
+ .messageKey("document")
+ .format(new MessageFormat("{0}"))
+ .arguments("line " + (i + 1) + ": the file has more than one YAML document (a " + t
+ + " separator): a Camel YAML file is one document, remove the " + t
+ + " and everything after it")
+ .build();
+ }
+ } else if (!t.isEmpty() && !t.startsWith("#") && !t.startsWith("%")) {
+ seenContent = true;
+ }
+ }
+ return null;
+ }
+
private List<Error> validate(JsonNode target) {
var errors = filterOneOfNoise(new ArrayList<>(schema.validate(target)));
errors.removeIf(YamlValidator::isRuntimeAcceptedScalar);
errors = withExpressionHints(errors);
+ errors = withPropertyHints(errors);
+ errors = withListHints(errors);
+ errors = withStepHints(errors);
+ // CAMEL-24707: the schema requires the expression, so a node without one fails its oneOf with "0 are valid"
+ // 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);
+ for (Error m : missing) {
+ String at = String.valueOf(m.getInstanceLocation());
+ errors.removeIf(e -> String.valueOf(e.getInstanceLocation()).equals(at)
+ && ("oneOf".equals(e.getKeyword()) || "required".equals(e.getKeyword())));
+ }
+ errors.addAll(missing);
+ // an unknown property that got a hint (bean: as a language, a header name as the key...) is the cause; the
+ // oneOf and required errors the strict schema adds at the same location only repeat it thirty times
+ java.util.Set<String> hinted = new java.util.HashSet<>();
+ for (Error e : errors) {
+ if ("additionalProperties".equals(e.getKeyword())) {
+ hinted.add(String.valueOf(e.getInstanceLocation()));
+ }
+ }
+ if (!hinted.isEmpty()) {
+ errors.removeIf(e -> hinted.contains(String.valueOf(e.getInstanceLocation()))
+ && ("oneOf".equals(e.getKeyword()) || "required".equals(e.getKeyword())));
+ }
+ if (errors.isEmpty()) {
+ checkSimpleSyntaxInScripts(target, new NodePath(PathType.JSON_POINTER), errors);
+ }
if (canonical) {
checkOneOfCardinality(target, new NodePath(PathType.JSON_POINTER), errors);
}
return errors;
}
+ /**
+ * "must have at most 1 properties" at a step: a step holds one EIP, and the second key is either an option that
+ * belongs under the EIP (indented one level more) or another step (its own - item).
+ */
+ static List<Error> withStepHints(List<Error> errors) {
+ List<Error> answer = new ArrayList<>(errors.size());
+ for (Error error : errors) {
+ String location = String.valueOf(error.getInstanceLocation());
+ if ("maxProperties".equals(error.getKeyword()) && location.matches("/\\d+")) {
+ // - beans:\n myBean: ... : the second key was meant to be inside the first; it is not indented enough
+ answer.add(Error.builder()
+ .keyword("maxProperties")
+ .instanceLocation(error.getInstanceLocation())
+ .messageKey("maxProperties")
+ .format(new MessageFormat("{0}"))
+ .arguments(error.getMessage() + " (a top-level entry is one key: - route:, - beans:, - rest:...;"
+ + " the lines that belong to it must be indented under it, a second key at the same"
+ + " level as the entry is read as a separate property)")
+ .build());
+ continue;
+ }
+ if ("maxProperties".equals(error.getKeyword()) && location.matches(".*/steps/\\d+")) {
+ answer.add(Error.builder()
+ .keyword("maxProperties")
+ .instanceLocation(error.getInstanceLocation())
+ .messageKey("maxProperties")
+ .format(new MessageFormat("{0}"))
+ .arguments(error.getMessage() + " (a step is one EIP: an option of that EIP is indented under"
+ + " its key, and the next EIP is its own - item)")
+ .build());
+ } else {
+ answer.add(error);
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * The EIPs whose expression the runtime needs: the schema leaves it optional for every expression node, and a split
+ * written with only delimiter: "," (or a filter, setBody, when... with only options) fails when the route is
+ * created with "Unsupported definition: null".
+ */
+ private static final Set<String> LOG_COMPONENT_OPTIONS = Set.of(
+ "showAll", "showBody", "showBodyType", "showHeaders", "showExchangePattern", "showProperties",
+ "showAllProperties", "showVariables", "showExchangeId", "showException", "showCaughtException",
+ "showStackTrace", "showStreams", "showFiles", "showFuture", "showRouteId", "showRouteGroup", "multiline",
+ "maxChars", "skipBodyLineSeparator", "groupSize", "groupInterval", "groupDelay", "groupActiveOnly",
+ "level", "plain", "sourceLocationLoggerName", "style");
+
+ private static final Set<String> EXPRESSION_REQUIRED = Set.of(
+ "split", "filter", "when", "setBody", "setHeader", "setProperty", "setVariable", "transform", "loop",
+ "delay", "recipientList", "routingSlip", "dynamicRouter", "validate", "script", "throttle", "resequence",
+ "idempotentConsumer");
+
+ private static final Map<String, String> EXPRESSION_EXAMPLES = Map.of(
+ "split", "split: {tokenize: \",\"} or split: {simple: \"${body}\"} (delimiter only applies to the result of"
+ + " the expression)",
+ "filter", "filter: {simple: \"${header.type} == 'urgent'\"}",
+ "when", "when: {simple: \"${body} contains 'x'\"}",
+ "setBody", "setBody: {simple: \"Hello ${body}\"} or setBody: {constant: \"Hello\"}",
+ "setHeader", "setHeader: {name: id, simple: \"${exchangeId}\"}",
+ "loop", "loop: {constant: \"3\"}",
+ "recipientList", "recipientList: {simple: \"${header.to}\"}",
+ "script", "script: {groovy: \"...\"}",
+ "delay", "delay: {constant: \"1000\"}");
+
+ private static final Set<String> SCRIPT_LANGUAGES = Set.of("groovy", "js", "python", "python3", "mvel", "ognl",
+ "jq", "jsonpath", "xpath", "xquery", "spel", "jactl", "java", "joor", "quickjs", "wasm", "datasonnet");
+
+ /**
+ * ${body.value} < 1 written as a groovy expression: Simple syntax inside another language, which groovy reads as a
+ * call to a method named $ and jsonpath as an invalid path. Says which language it is and how to write it there.
+ */
+ void checkSimpleSyntaxInScripts(JsonNode node, NodePath path, List<Error> errors) {
+ if (node == null) {
+ return;
+ }
+ if (node.isArray()) {
+ for (int i = 0; i < node.size(); i++) {
+ checkSimpleSyntaxInScripts(node.get(i), path.append(i), errors);
+ }
+ return;
+ }
+ if (!node.isObject()) {
+ return;
+ }
+ var fields = node.fieldNames();
+ while (fields.hasNext()) {
+ String name = fields.next();
+ JsonNode value = node.get(name);
+ String text = null;
+ if (SCRIPT_LANGUAGES.contains(name)) {
+ if (value.isTextual()) {
+ text = value.asText();
+ } else if (value.isObject() && value.has("expression") && value.get("expression").isTextual()) {
+ text = value.get("expression").asText();
+ }
+ }
+ String outsideQuotes
+ = text != null ? text.replaceAll("\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'", "\"\"") : null;
+ if (outsideQuotes != null && outsideQuotes.contains("${") && !name.equals("js") && !name.equals("quickjs")) {
+ // "${x}" inside quotes is a groovy GString (or a JS template) and is fine; ${...} outside quotes is simple
+ String example;
+ if (name.equals("groovy") || name.equals("mvel") || name.equals("ognl") || name.equals("jactl")) {
+ example = "body.value < 1, headers.foo, exchange.getIn().getBody()";
+ } else if (name.equals("jsonpath") || name.equals("jq")) {
+ example = "$.value for a JSON body";
+ } else if (name.equals("xpath") || name.equals("xquery")) {
+ example = "/order/value for an XML body";
+ } else {
+ example = "the language's own syntax";
+ }
+ errors.add(Error.builder()
+ .keyword("type")
+ .instanceLocation(path.append(name))
+ .messageKey("type")
+ .format(new MessageFormat("{0}"))
+ .arguments(name + ": ${...} is simple syntax, not " + name + ": write the expression in " + name
+ + " (" + example + "), or use simple: \"" + text.replace("\"", "'") + "\"")
+ .build());
+ }
+ checkSimpleSyntaxInScripts(value, path.append(name), errors);
+ }
+ }
+
+ /** Adds an error for every expression node in the tree that has neither expression: nor a language key. */
+ void checkRequiredExpressions(JsonNode node, NodePath path, List<Error> errors) {
+ if (node == null) {
+ return;
+ }
+ if (node.isArray()) {
+ for (int i = 0; i < node.size(); i++) {
+ checkRequiredExpressions(node.get(i), path.append(i), errors);
+ }
+ return;
+ }
+ if (!node.isObject()) {
+ return;
+ }
+ var fields = node.fieldNames();
+ while (fields.hasNext()) {
+ String name = fields.next();
+ JsonNode value = node.get(name);
+ if ("when".equals(name) && value != null && value.isArray()) {
+ // choice: {when: [...]}: each item is an expression node
+ for (int i = 0; i < value.size(); i++) {
+ JsonNode item = value.get(i);
+ if (item != null && item.isObject() && !hasExpression(item)) {
+ errors.add(Error.builder()
+ .keyword("required")
+ .instanceLocation(path.append(name).append(i))
+ .messageKey("required")
+ .format(new MessageFormat("{0}"))
+ .arguments("when has no expression: write the language as a key, for example "
+ + EXPRESSION_EXAMPLES.get("when"))
+ .build());
+ }
+ }
+ }
+ if (EXPRESSION_REQUIRED.contains(name) && (value == null || value.isNull() || value.isObject())
+ && !hasExpression(value)) {
+ String example = EXPRESSION_EXAMPLES.getOrDefault(name,
+ name + ": {simple: \"...\"} or " + name + ": {constant: \"...\"}");
+ errors.add(Error.builder()
+ .keyword("required")
+ .instanceLocation(path.append(name))
+ .messageKey("required")
+ .format(new MessageFormat("{0}"))
+ .arguments(name + " has no expression: write the language as a key, for example " + example)
+ .build());
+ }
+ checkRequiredExpressions(value, path.append(name), errors);
+ }
+ }
+
+ private boolean hasExpression(JsonNode value) {
+ if (value == null || value.isNull() || !value.isObject()) {
+ return true;
+ }
+ if (value.has("expression")) {
+ return true;
+ }
+ for (String language : languageKeys) {
+ if (value.has(language)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/**
* Filters noise from {@code oneOf} validation. When a {@code oneOf} has N branches and none match, the validator
* reports errors from ALL branches — producing dozens of "required property 'X' not found" messages for branches
@@ -432,6 +786,332 @@ public class YamlValidator {
private static final String EXPRESSION_SUB_ELEMENT = "ExpressionSubElementDefinition";
+ private JsonNode model;
+ private Set<String> topLevelEntries = Set.of();
+ private Set<String> languageKeys = Set.of();
+ private Set<String> stepNames = Set.of();
+ private Set<String> resilienceProperties = Set.of();
+
+ /**
+ * "object found, array expected" says what the schema wants, not how to write it: a list, each item starting with
+ * "- ". At the root of the file it also names the entries (route, from, beans, rest, onException).
+ */
+ static List<Error> withListHints(List<Error> errors) {
+ List<Error> answer = new ArrayList<>(errors.size());
+ for (Error error : errors) {
+ answer.add(withListHint(error));
+ }
+ return answer;
+ }
+
+ static Error withListHint(Error error) {
+ if (!"type".equals(error.getKeyword()) || error.getMessage() == null) {
+ return error;
+ }
+ String location = String.valueOf(error.getInstanceLocation());
+ if (location.endsWith("/language") && error.getMessage().contains("object expected")) {
+ // script: {language: groovy, text: ...}: the language is the key of the expression, not a property
+ return Error.builder()
+ .keyword("type")
+ .instanceLocation(error.getInstanceLocation())
+ .messageKey("type")
+ .format(new MessageFormat("{0}"))
+ .arguments(error.getMessage() + " (an expression is written with the language as the key, e.g."
+ + " groovy: \"...\", simple: \"...\", constant: \"...\"; the language: form is"
+ + " language: {language: groovy, expression: \"...\"})")
+ .build();
+ }
+ if (error.getMessage().contains("object found, string expected")) {
+ // message: {simple: "..."}: a string property that is already an expression, or a plain option
+ String prop = location.substring(location.lastIndexOf('/') + 1);
+ return Error.builder()
+ .keyword("type")
+ .instanceLocation(error.getInstanceLocation())
+ .messageKey("type")
+ .format(new MessageFormat("{0}"))
+ .arguments(error.getMessage() + " (" + prop + " is a plain string"
+ + (location.endsWith("/log/message")
+ ? " that is already a simple expression: write message: \"... ${body} ...\""
+ : ": write " + prop + ": \"...\", not a language map")
+ + ")")
+ .build();
+ }
+ if (location.matches(
+ "/\\d+/(onException|onCompletion|intercept|interceptFrom|interceptSendToEndpoint|errorHandler|route|rest|restConfiguration)")
+ && error.getMessage().contains("array found, object expected")) {
+ // - onException: [ ... ]: the entry is a map; several handlers are several - onException: items
+ String entry = location.substring(location.lastIndexOf('/') + 1);
+ return Error.builder()
+ .keyword("type")
+ .instanceLocation(error.getInstanceLocation())
+ .messageKey("type")
+ .format(new MessageFormat("{0}"))
+ .arguments(error.getMessage() + " (" + entry + " is a map, not a list: - " + entry + ": followed by its"
+ + " properties indented" + (entry.equals("onException")
+ ? " (exception: [java.lang.Exception], handled: {constant: \"true\"}, steps: [...])"
+ : "")
+ + "; several of them are several - " + entry + ": items)")
+ .build();
+ }
+ if (location.matches(".*/(otherwise|doTry|doFinally|doCatch/\\d+)") && error.getMessage().contains("object expected")) {
+ // otherwise: [- log: ...]: the block is a map whose steps: holds the list
+ String eip = location.substring(location.lastIndexOf('/') + 1);
+ if (eip.matches("\\d+")) {
+ eip = "doCatch";
+ }
+ return Error.builder()
+ .keyword("type")
+ .instanceLocation(error.getInstanceLocation())
+ .messageKey("type")
+ .format(new MessageFormat("{0}"))
+ .arguments(error.getMessage() + " (" + eip + " holds its EIPs under steps: " + eip
+ + ": {steps: [- log: \"...\"]}" + (eip.equals("doCatch")
+ ? ", each - doCatch: item with"
+ + " exception: and steps:"
+ : "")
+ + ")")
+ .build();
+ }
+ if (!error.getMessage().contains("array expected")) {
+ return error;
+ }
+ String name = location.substring(location.lastIndexOf('/') + 1);
+ String hint;
+ if (location.isEmpty() || location.equals("/")) {
+ hint = "a Camel YAML file is a list of entries, each starting with \"- \": - route:, - from:, - beans:, - rest:,"
+ + " - onException:";
+ } else if (name.equals("beans")) {
+ hint = "beans is a list: - name: myBean followed by type: \"#class:com.example.MyBean\" (indented under the -)";
+ } else if (name.equals("steps") || name.equals("when") || name.equals("get") || name.equals("post")
+ || name.equals("exception") || name.equals("doCatch")) {
+ hint = name + " is a list: each item starts with \"- \"";
+ } else {
+ hint = "write it as a list: each item starts with \"- \"";
+ }
+ return Error.builder()
+ .keyword("type")
+ .instanceLocation(error.getInstanceLocation())
+ .messageKey("type")
+ .format(new MessageFormat("{0}"))
+ .arguments(error.getMessage() + " (" + hint + ")")
+ .build();
+ }
+
+ /**
+ * Adds a hint to "property 'x' is not defined in the schema": the closest property name of that node (did you mean
+ * 'logName'?), or, when the property is a top-level entry such as onException written inside a route, where it goes
+ * instead.
+ */
+ List<Error> withPropertyHints(List<Error> errors) {
+ List<Error> answer = new ArrayList<>(errors.size());
+ for (Error error : errors) {
+ answer.add(withPropertyHint(error));
+ }
+ return answer;
+ }
+
+ Error withPropertyHint(Error error) {
+ if ("required".equals(error.getKeyword()) && error.getMessage() != null
+ && error.getMessage().contains("required property 'steps' not found")
+ && String.valueOf(error.getInstanceLocation()).matches(".*/route/from")) {
+ return Error.builder()
+ .keyword("required")
+ .instanceLocation(error.getInstanceLocation())
+ .messageKey("required")
+ .format(new MessageFormat("{0}"))
+ .arguments(error.getMessage() + " (steps: is a property of from:, next to uri:; a steps: written at"
+ + " the route level must be indented under from:)")
+ .build();
+ }
+ if (!"additionalProperties".equals(error.getKeyword()) || error.getMessage() == null) {
+ return error;
+ }
+ String message = error.getMessage();
+ String unknown = between(message, "property '", "'");
+ if (unknown == null) {
+ return error;
+ }
+ String location = String.valueOf(error.getInstanceLocation());
+ String hint = null;
+ if (location.matches("/\\d+/beans/\\d+")
+ && (unknown.equals("id") || unknown.equals("ref") || unknown.equals("class"))) {
+ // - id: myBean / class: ... : the bean properties are name and type
+ hint = "a bean is - name: myBean followed by type: \"#class:com.example.MyBean\" (name instead of " + unknown
+ + (unknown.equals("class") ? ", type instead of class" : "") + ")";
+ } else if (location.matches("/\\d+/beans/\\d+")) {
+ // - myBean: {type: ...} instead of - name: myBean / type: ...
+ hint = "a bean item is written as - name: " + unknown + " followed by type: \"#class:com.example.MyBean\" "
+ + "(the name is a property, not the key)";
+ } else if (topLevelEntries.contains(unknown) && location.chars().filter(c -> c == '/').count() >= 2) {
+ hint = "'" + unknown + "' is a top-level entry: write it as a list item at the same level as the route, "
+ + "not inside it";
+ } else if (location.matches(".*/(onException|doCatch/\\d+)") && unknown.matches("([a-z][\\w]*\\.)+[A-Z]\\w*")) {
+ // onException: {java.lang.Exception: ...}: the class is a list item under exception:
+ String eip = location.endsWith("/onException") ? "onException" : "doCatch";
+ hint = "the exception class is a list item under exception: (" + eip + ": {exception: [" + unknown
+ + "], steps: [...]})";
+ } else if (location.endsWith("/circuitBreaker") && unknown.equals("name")) {
+ hint = "the circuit breaker's name is its id: circuitBreaker: {id: myBreaker, ...}";
+ } else if (location.endsWith("/circuitBreaker") && !resilienceProperties.isEmpty()
+ && (resilienceProperties.contains(unknown) || closest(unknown, resilienceProperties) != null
+ || unknown.toLowerCase(Locale.ROOT).contains("threshold")
+ || unknown.toLowerCase(Locale.ROOT).contains("timeout"))) {
+ // circuitBreaker: {failureThreshold: 5}: the thresholds and timeouts are resilience4j configuration
+ String best = resilienceProperties.contains(unknown) ? unknown : closest(unknown, resilienceProperties);
+ hint = "the thresholds, timeouts and the like are written under resilience4jConfiguration: (circuitBreaker:"
+ + " {resilience4jConfiguration: {" + (best != null ? best : "failureRateThreshold") + ": ...}, steps:"
+ + " [...], onFallback: {steps: [...]}})";
+ } else if (location.endsWith("/log") && (unknown.equals("level") || unknown.equals("logLevel"))) {
+ hint = "did you mean 'loggingLevel'?";
+ } else if (location.endsWith("/log") && LOG_COMPONENT_OPTIONS.contains(unknown)) {
+ // log: {message: ..., showHeaders: true}: those are options of the log component endpoint
+ hint = "'" + unknown + "' is an option of the log component, not of the log EIP: write a to: step with"
+ + " uri: \"log:com.example?" + unknown + "=...\" (the log EIP has message, loggingLevel, logName,"
+ + " marker)";
+ } else if (unknown.equals("steps") && location.matches(".*/route")) {
+ // - route: {from: {uri: ...}, steps: [...]}: steps belongs under from:
+ hint = "steps: goes under from:, indented at the same level as uri: (route: {from: {uri: ..., steps: [...]}})";
+ } else if (stepNames.contains(unknown) && !location.matches(".*/steps/\\d+")
+ && location.matches(".*/(otherwise|when/\\d+|doTry|doCatch/\\d+|doFinally|split|filter|loop|aggregate"
+ + "|circuitBreaker|onFallback|multicast|pipeline|saga|resequence|throttle|delay"
+ + "|onException|onCompletion|intercept|interceptFrom|interceptSendToEndpoint|route|from)")) {
+ // otherwise: {log: ...} or when: [- simple: ..., log: ...]: the EIPs go under steps:
+ String eip = location.substring(location.lastIndexOf('/') + 1);
+ if (eip.matches("\\d+")) {
+ String parent = location.substring(0, location.lastIndexOf('/'));
+ eip = parent.substring(parent.lastIndexOf('/') + 1);
+ }
+ hint = "'" + unknown + "' is a step: the steps of " + eip + " go under steps: (" + eip
+ + ": {steps: [- " + unknown + ": ...]})";
+ } else if (unknown.equals("script") && !location.endsWith("/steps")
+ && (EXPRESSION_REQUIRED.contains(location.substring(location.lastIndexOf('/') + 1))
+ || location.endsWith("/expression"))) {
+ // setBody: {script: ...}: script is an EIP; the language is the key of an expression
+ hint = "script is an EIP step, not a language: write the language as the key of the expression (groovy:"
+ + " \"...\", simple: \"...\"), or run a script as its own step with - script: {groovy: \"...\"}";
+ } else if (unknown.equals("bean") && !location.endsWith("/steps")) {
+ // setBody: {bean: myBean} : the bean language is method:
+ hint = "the bean language is written as method: (method: {ref: myBean, method: process}), or call the bean"
+ + " as a step with - bean: {ref: myBean, method: process}";
+ } else if (location.matches(".*/(setHeader|setProperty|setVariable|removeHeader|removeProperty|removeVariable)")
+ && closest(unknown, knownProperties(String.valueOf(error.getSchemaLocation()))) == null) {
+ // setHeader: {CamelNumberA: {simple: ...}} : the name is a property, not the key
+ String eip = location.substring(location.lastIndexOf('/') + 1);
+ hint = "the name is a property: " + eip + ": {name: " + unknown
+ + (eip.startsWith("set") ? ", simple: \"...\"}" : "}") + " (" + unknown + " is not the key)";
+ } else if (location.endsWith("/bean")
+ && (unknown.equals("parameters") || unknown.equals("args") || unknown.equals("arguments"))) {
+ hint = "arguments are written in the method call: bean: {ref: myBean, method: \"process(${body}, 'x')\"}";
+ } else {
+ String best = closest(unknown, knownProperties(String.valueOf(error.getSchemaLocation())));
+ if (best != null) {
+ hint = "did you mean '" + best + "'?";
+ }
+ }
+ if (hint == null) {
+ return error;
+ }
+ return Error.builder()
+ .keyword("additionalProperties")
+ .instanceLocation(error.getInstanceLocation())
+ .messageKey("additionalProperties")
+ .format(new MessageFormat("{0}"))
+ .arguments(message + " (" + hint + ")")
+ .build();
+ }
+
+ /** The property names the schema allows at the definition an additionalProperties error points to. */
+ Set<String> knownProperties(String schemaLocation) {
+ Set<String> answer = new LinkedHashSet<>();
+ if (model == null) {
+ return answer;
+ }
+ int hash = schemaLocation.indexOf('#');
+ String pointer = hash >= 0 ? schemaLocation.substring(hash + 1) : schemaLocation;
+ if (pointer.endsWith("/additionalProperties")) {
+ pointer = pointer.substring(0, pointer.length() - "/additionalProperties".length());
+ }
+ collectProperties(model.at(pointer), answer, 0);
+ return answer;
+ }
+
+ private void collectProperties(JsonNode node, Set<String> answer, int depth) {
+ if (node == null || node.isMissingNode() || depth > 3) {
+ return;
+ }
+ JsonNode props = node.get("properties");
+ if (props != null) {
+ props.fieldNames().forEachRemaining(answer::add);
+ }
+ JsonNode ref = node.get("$ref");
+ if (ref != null && ref.isTextual() && ref.asText().startsWith("#")) {
+ collectProperties(model.at(ref.asText().substring(1)), answer, depth + 1);
+ }
+ for (String composition : new String[] { "anyOf", "oneOf", "allOf" }) {
+ JsonNode entries = node.get(composition);
+ if (entries != null && entries.isArray()) {
+ for (JsonNode entry : entries) {
+ if (!entry.has("not")) {
+ collectProperties(entry, answer, depth + 1);
+ }
+ }
+ }
+ }
+ }
+
+ static String closest(String unknown, Set<String> known) {
+ String best = null;
+ int bestDistance = Integer.MAX_VALUE;
+ int threshold = Math.max(2, unknown.length() / 3);
+ String u = unknown.toLowerCase(Locale.ROOT);
+ for (String k : known) {
+ int d = distance(u, k.toLowerCase(Locale.ROOT));
+ if (d <= threshold && (d < bestDistance || d == bestDistance && k.length() < best.length())) {
+ best = k;
+ bestDistance = d;
+ }
+ }
+ if (best == null) {
+ // propertyName, headerName, variableName: the schema property is the last word (name)
+ for (String k : known) {
+ String kl = k.toLowerCase(Locale.ROOT);
+ if (kl.length() >= 3 && u.length() > kl.length() && u.endsWith(kl)
+ && (best == null || k.length() > best.length())) {
+ best = k;
+ }
+ }
+ }
+ return best;
+ }
+
+ static int distance(String a, String b) {
+ int[] prev = new int[b.length() + 1];
+ int[] cur = new int[b.length() + 1];
+ for (int j = 0; j <= b.length(); j++) {
+ prev[j] = j;
+ }
+ for (int i = 1; i <= a.length(); i++) {
+ cur[0] = i;
+ for (int j = 1; j <= b.length(); j++) {
+ int cost = a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1;
+ cur[j] = Math.min(Math.min(cur[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost);
+ }
+ int[] t = prev;
+ prev = cur;
+ cur = t;
+ }
+ return prev[b.length()];
+ }
+
+ private static String between(String text, String start, String end) {
+ int i = text.indexOf(start);
+ if (i < 0) {
+ return null;
+ }
+ int j = text.indexOf(end, i + start.length());
+ return j < 0 ? null : text.substring(i + start.length(), j);
+ }
+
private static boolean isBooleanText(String text) {
String s = text.trim();
return "true".equalsIgnoreCase(s) || "false".equalsIgnoreCase(s);
@@ -482,6 +1162,18 @@ public class YamlValidator {
public void init() throws Exception {
String location = canonical ? LOCATION_CANONICAL : LOCATION;
var model = mapper.readTree(YamlValidator.class.getResourceAsStream(location));
+ this.model = model;
+ this.topLevelEntries = new LinkedHashSet<>();
+ model.at("/items/properties").fieldNames().forEachRemaining(topLevelEntries::add);
+ this.stepNames = new LinkedHashSet<>();
+ model.at("/items/definitions/org.apache.camel.model.ProcessorDefinition/properties").fieldNames()
+ .forEachRemaining(stepNames::add);
+ this.resilienceProperties = new LinkedHashSet<>();
+ model.at("/items/definitions/org.apache.camel.model.Resilience4jConfigurationDefinition/properties").fieldNames()
+ .forEachRemaining(resilienceProperties::add);
+ this.languageKeys = new LinkedHashSet<>();
+ model.at("/items/definitions/org.apache.camel.model.language.ExpressionDefinition/properties").fieldNames()
+ .forEachRemaining(languageKeys::add);
var version = getSpecificationVersion(model).orElse(SpecificationVersion.DRAFT_4);
// no typeLoose: besides accepting quoted scalars it also accepts a single value where the schema expects a
// list (steps: written as a map), which the runtime rejects. The scalar leniency the runtime has is done as
diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java
new file mode 100644
index 000000000000..87ebfe43a8ba
--- /dev/null
+++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java
@@ -0,0 +1,623 @@
+/*
+ * 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.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-24698: "property 'x' is not defined in the schema" should say what was probably meant: the closest option name,
+ * or that the entry belongs at the top level of the file.
+ */
+public class YamlValidatorPropertyHintTest {
+
+ private static YamlValidator validator;
+
+ @BeforeAll
+ public static void setup() throws Exception {
+ validator = new YamlValidator();
+ validator.init();
+ }
+
+ @Test
+ public void testMisspelledOptionGetsTheClosestName() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - log:
+ message: "hi"
+ loggerName: sensor
+ """);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).getMessage()).contains("loggerName").contains("did you mean 'logName'?");
+ }
+
+ @Test
+ public void testTopLevelEntryInsideARouteSaysWhereItGoes() throws Exception {
+ List<Error> errors = validator.validate("""
+ - route:
+ id: r
+ onException:
+ exception:
+ - java.lang.Exception
+ steps:
+ - log:
+ message: "error"
+ from:
+ uri: timer:tick
+ steps:
+ - log:
+ message: "hi"
+ """);
+ assertThat(errors).isNotEmpty();
+ assertThat(errors).anyMatch(e -> e.getMessage().contains("'onException' is a top-level entry"));
+ }
+
+ @Test
+ public void testFarOffNameGetsNoGuess() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - split:
+ tokenize: ","
+ cheese: true
+ steps:
+ - log:
+ message: "hi"
+ """);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).getMessage()).contains("cheese").doesNotContain("did you mean");
+ }
+
+ @Test
+ public void testSecondDocumentIsReported() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - log:
+ message: "hi"
+ ---
+ Run it with camel run and you will see hi every second.
+ """);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).getMessage()).contains("line 6").contains("more than one YAML document");
+ // a leading document marker is fine
+ assertThat(validator.validate("""
+ ---
+ - from:
+ uri: timer:tick
+ steps:
+ - log:
+ message: "hi"
+ """)).isEmpty();
+ }
+
+ @Test
+ public void testMapWhereAListIsExpectedSaysHowToWriteIt() throws Exception {
+ List<Error> errors = validator.validate("""
+ route:
+ from:
+ uri: timer:tick
+ steps:
+ - log:
+ message: "hi"
+ """);
+ assertThat(errors).anyMatch(e -> e.getMessage().contains("array expected") && e.getMessage().contains("- route:"));
+
+ errors = validator.validate("""
+ - beans:
+ myBean:
+ type: "#class:com.example.MyBean"
+ - from:
+ uri: timer:tick
+ steps:
+ - log:
+ message: "hi"
+ """);
+ assertThat(errors).anyMatch(e -> e.getMessage().contains("beans is a list: - name: myBean"));
+
+ errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ log:
+ message: "hi"
+ """);
+ assertThat(errors).anyMatch(e -> e.getMessage().contains("steps is a list"));
+ }
+
+ @Test
+ public void testBeanItemKeyedByNameSaysHowToWriteIt() throws Exception {
+ List<Error> errors = validator.validate("""
+ - beans:
+ - myBean:
+ type: "#class:com.example.MyBean"
+ - from:
+ uri: timer:tick
+ steps:
+ - log:
+ message: "hi"
+ """);
+ assertThat(errors).anyMatch(e -> e.getMessage().contains("a bean item is written as - name: myBean"));
+ }
+
+ @Test
+ public void testJbangDepsLineIsNamed() throws Exception {
+ List<Error> errors = validator.validate("""
+ //DEPS org.apache.camel:camel-groovy
+
+ - from:
+ uri: timer:tick
+ steps:
+ - log:
+ message: "hi"
+ """);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).getMessage()).contains("line 1: //DEPS is read as text by YAML")
+ .contains("# //DEPS org.apache.camel:camel-groovy").contains("camel.jbang.dependencies");
+ // the comment form is what camel-jbang reads, and it is plain YAML
+ assertThat(validator.validate("""
+ # //DEPS org.apache.camel:camel-groovy
+ - from:
+ uri: timer:tick
+ steps:
+ - log:
+ message: "hi"
+ """)).isEmpty();
+ }
+
+ @Test
+ public void testProseAfterTheRoutesIsNamed() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - log:
+ message: "hi"
+
+ Run it with camel run and you will see hi every second.
+ """);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).getMessage()).startsWith("line 7 is not YAML (\"Run it with camel run").contains("# comment");
+ }
+
+ @Test
+ public void testProseAfterAListItemNamesTheProseLine() throws Exception {
+ // the parser reports the list item on line 1 first and the prose line last; the prose line is the problem
+ List<Error> errors = validator.validate("""
+ - route:
+ from:
+ uri: "timer:tick?period=5000"
+ steps:
+ - log:
+ message: "tick"
+
+ To run it, execute in the same directory:
+ camel run timer-log.camel.yaml
+ """);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).getMessage()).startsWith("line 8 is not YAML (\"To run it, execute").contains("# comment");
+ }
+
+ @Test
+ public void testPropertyNameSuggestsName() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - setProperty:
+ propertyName: attempt
+ constant: "1"
+ """);
+ assertThat(errors).anySatisfy(
+ e -> assertThat(e.getMessage()).contains("property 'propertyName' is not defined")
+ .contains("did you mean 'name'?"));
+ }
+
+ @Test
+ public void testSplitWithoutAnExpressionIsReported() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - setBody:
+ constant: "a,b,c"
+ - split:
+ delimiter: ","
+ streaming: true
+ steps:
+ - log: "${body}"
+ - filter:
+ steps:
+ - log: "kept"
+ """);
+ assertThat(errors).hasSize(2);
+ assertThat(errors.get(0).getInstanceLocation().toString()).isEqualTo("/0/from/steps/1/split");
+ assertThat(errors.get(0).getMessage()).startsWith("split has no expression: write the language as a key")
+ .contains("tokenize").contains("delimiter only applies");
+ assertThat(errors.get(1).getMessage()).startsWith("filter has no expression");
+ }
+
+ @Test
+ public void testExpressionNodesWithAnExpressionAreFine() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - split:
+ tokenize: ","
+ steps:
+ - log: "${body}"
+ - setHeader:
+ name: id
+ expression:
+ simple: "${exchangeId}"
+ - choice:
+ when:
+ - simple: "${body} != null"
+ steps:
+ - log: "x"
+ """);
+ assertThat(errors).isEmpty();
+ }
+
+ @Test
+ public void testTwoKeysInOneStepSayAStepIsOneEip() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - to:
+ uri: file:input?fileName=customers.xml
+ steps: []
+ - log: "done"
+ """);
+ assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("must have at most 1 properties")
+ .contains("a step is one EIP"));
+ }
+
+ @Test
+ public void testBeanAsALanguageSaysMethod() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - setBody:
+ bean: myProcessor
+ """);
+ assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'bean' is not defined")
+ .contains("the bean language is written as method:"));
+ // the strict schema's oneOf and per-language required errors at the same node are dropped
+ assertThat(errors).noneSatisfy(e -> assertThat(e.getMessage()).contains("0 are valid"));
+ assertThat(errors).hasSize(1);
+ }
+
+ @Test
+ public void testHeaderNameAsTheKeySaysName() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - setHeader:
+ CamelNumberA:
+ constant: "5"
+ - bean:
+ ref: calc
+ parameters: [1, 2]
+ """);
+ assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'CamelNumberA' is not defined")
+ .contains("the name is a property: setHeader: {name: CamelNumberA, simple: \"...\"}"));
+ assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'parameters' is not defined")
+ .contains("arguments are written in the method call"));
+ }
+
+ @Test
+ public void testOtherwiseAsAListSaysSteps() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - choice:
+ when:
+ - simple: "${body} != null"
+ steps:
+ - log: "a"
+ otherwise:
+ - log: "b"
+ """);
+ assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("array found, object expected")
+ .contains("otherwise holds its EIPs under steps: otherwise: {steps: [- log: \"...\"]}"));
+ }
+
+ @Test
+ public void testStepDirectlyUnderOtherwiseOrWhenSaysSteps() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - choice:
+ when:
+ - simple: "${body} != null"
+ log: "a"
+ otherwise:
+ log: "b"
+ """);
+ assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'log' is not defined")
+ .contains("the steps of otherwise go under steps: (otherwise: {steps: [- log: ...]})"));
+ assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'log' is not defined")
+ .contains("the steps of when go under steps:"));
+ }
+
+ @Test
+ public void testStepsAtTheRouteLevelSaysUnderFrom() throws Exception {
+ List<Error> errors = validator.validate("""
+ - route:
+ id: r
+ from:
+ uri: timer:tick
+ steps:
+ - log: "a"
+ """);
+ assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'steps' is not defined")
+ .contains("steps: goes under from:"));
+ assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("required property 'steps' not found")
+ .contains("indented under from:"));
+ }
+
+ @Test
+ public void testSecondKeyAtTheTopLevelSaysIndent() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - log: "a"
+ - beans:
+ myBean:
+ type: "#class:com.example.MyBean"
+ """);
+ assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("must have at most 1 properties")
+ .contains("must be indented under it"));
+ }
+
+ @Test
+ public void testLogComponentOptionOnTheLogEipSaysToStep() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - log:
+ message: "a"
+ showHeaders: true
+ level: INFO
+ """);
+ assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'showHeaders' is not defined")
+ .contains("is an option of the log component, not of the log EIP")
+ .contains("log:com.example?showHeaders=..."));
+ assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'level' is not defined")
+ .contains("did you mean 'loggingLevel'?"));
+ }
+
+ @Test
+ public void testExceptionClassAsTheKeySaysList() throws Exception {
+ List<Error> errors = validator.validate("""
+ - onException:
+ java.lang.Exception:
+ handled:
+ constant: "true"
+ - from:
+ uri: timer:tick
+ steps:
+ - log: "a"
+ """);
+ assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'java.lang.Exception' is not defined")
+ .contains(
+ "the exception class is a list item under exception: (onException: {exception: [java.lang.Exception]"));
+ }
+
+ @Test
+ public void testSimpleSyntaxInsideGroovyIsNamed() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - choice:
+ when:
+ - groovy: "${body.value} < 1"
+ steps:
+ - log: "small"
+ otherwise:
+ steps:
+ - log: "big"
+ - setBody:
+ groovy: "body.value * 2"
+ """);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).getMessage())
+ .startsWith("groovy: ${...} is simple syntax, not groovy: write the expression in groovy (body.value < 1")
+ .contains("or use simple: \"${body.value} < 1\"");
+ }
+
+ @Test
+ public void testOnExceptionAsAListSaysMap() throws Exception {
+ List<Error> errors = validator.validate("""
+ - onException:
+ - exception:
+ - java.lang.Exception
+ steps:
+ - log: "oops"
+ - from:
+ uri: timer:tick
+ steps:
+ - log: "a"
+ """);
+ assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("array found, object expected")
+ .contains("onException is a map, not a list").contains("several - onException: items"));
+ }
+
+ @Test
+ public void testWhenItemWithoutAnExpressionIsReportedOnce() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - choice:
+ when:
+ - steps:
+ - log: "a"
+ """);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).getInstanceLocation().toString()).isEqualTo("/0/from/steps/0/choice/when/0");
+ assertThat(errors.get(0).getMessage()).startsWith("when has no expression");
+ }
+
+ @Test
+ public void testSortWithoutAnExpressionIsFine() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - sort:
+ comparator: "#cmp"
+ - log: "sorted"
+ """);
+ assertThat(errors).isEmpty();
+ }
+
+ @Test
+ public void testResilienceOptionsOnTheCircuitBreakerSayConfiguration() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - circuitBreaker:
+ name: myBreaker
+ failureThreshold: 5
+ steps:
+ - to: "http://example.com"
+ """);
+ assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'name' is not defined")
+ .contains("the circuit breaker's name is its id"));
+ assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'failureThreshold' is not defined")
+ .contains("resilience4jConfiguration: {failureRateThreshold: ...}"));
+ }
+
+ @Test
+ public void testLogMessageAsAnExpressionMapSaysPlainString() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - log:
+ message:
+ simple: "Body: ${body}"
+ """);
+ assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("object found, string expected")
+ .contains("message is a plain string that is already a simple expression"));
+ }
+
+ @Test
+ public void testGroovyGStringInsideQuotesIsFine() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - setBody:
+ groovy: |
+ def t = exchange.getIn().getHeader('type')
+ "type is ${t}"
+ """);
+ assertThat(errors).isEmpty();
+ }
+
+ @Test
+ public void testScriptAsALanguageKeySaysLanguage() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - setBody:
+ script: "body.toUpperCase()"
+ """);
+ assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'script' is not defined")
+ .contains("script is an EIP step, not a language"));
+ }
+
+ @Test
+ public void testValueContinuingAfterItsQuoteIsNamed() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - log:
+ message: ">>> [INFO] " + exchange.getIn().getBody()
+ """);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).getMessage()).startsWith("line 5: the value of message continues after its closing quote")
+ .contains("no concatenation");
+ }
+
+ @Test
+ public void testLanguageAsAPropertySaysToUseItAsTheKey() throws Exception {
+ List<Error> errors = validator.validate("""
+ - from:
+ uri: timer:tick
+ steps:
+ - script:
+ language: groovy
+ text: "println 'hi'"
+ """);
+ assertThat(errors).anyMatch(
+ e -> e.getMessage().contains("the language as the key") && e.getMessage().contains("groovy: \"...\""));
+ }
+
+ @Test
+ public void testBeanWithIdInsteadOfNameSaysSo() throws Exception {
+ List<Error> errors = validator.validate("""
+ - beans:
+ - id: myBean
+ type: "#class:com.example.MyBean"
+ - from:
+ uri: timer:tick
+ steps:
+ - log:
+ message: "hi"
+ """);
+ assertThat(errors).anyMatch(e -> e.getMessage().contains("name instead of id"));
+ }
+
+ @Test
+ public void testEmptyFileSaysWhatAFileIs() throws Exception {
+ List<Error> errors = validator.validate("# nothing here yet\n");
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).getMessage()).contains("no YAML").contains("- route:");
+ }
+
+ @Test
+ public void testDistance() {
+ assertThat(YamlValidator.distance("loggername", "logname")).isEqualTo(3);
+ assertThat(YamlValidator.closest("loggerName", java.util.Set.of("logName", "message", "marker"))).isEqualTo("logName");
+ assertThat(YamlValidator.closest("cheese", java.util.Set.of("steps", "id"))).isNull();
+ }
+}
diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorScalarLeniencyTest.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorScalarLeniencyTest.java
index 7e3d6ba5b807..961f31a64cb9 100644
--- a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorScalarLeniencyTest.java
+++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorScalarLeniencyTest.java
@@ -195,6 +195,9 @@ public class YamlValidatorScalarLeniencyTest {
steps:
- %s:
%s
+ expression:
+ tokenize:
+ token: ","
steps:
- log:
message: "${body}"
diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/java/org/apache/camel/dsl/yaml/YamlRoutesBuilderLoader.java b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/java/org/apache/camel/dsl/yaml/YamlRoutesBuilderLoader.java
index de4298dc917b..87b35d19718c 100644
--- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/java/org/apache/camel/dsl/yaml/YamlRoutesBuilderLoader.java
+++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/java/org/apache/camel/dsl/yaml/YamlRoutesBuilderLoader.java
@@ -677,7 +677,13 @@ public class YamlRoutesBuilderLoader extends YamlRoutesBuilderLoaderSupport {
composer.getSingleNode()
.map(node -> preParseNode(ctx, node));
} catch (Exception e) {
- throw new RuntimeCamelException("Error pre-parsing resource: " + ctx.getResource().getLocation(), e);
+ String hint = "";
+ if (e.getMessage() != null && e.getMessage().contains("single document")) {
+ // a --- separator followed by more text, often an explanation appended to the route file
+ hint = ": the file has more than one YAML document (a --- separator); a Camel YAML file is one"
+ + " document, remove the --- and everything after it";
+ }
+ throw new RuntimeCamelException("Error pre-parsing resource: " + ctx.getResource().getLocation() + hint, e);
} finally {
ctx.close();
}