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 <[email protected]> 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 <[email protected]> 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(); }
