This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-25952 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 35c639870256a0f1679d08c92d99873c3317656b Author: Claus Ibsen <[email protected]> AuthorDate: Thu Oct 22 08:09:25 2026 +0200 CAMEL-25952: camel-jbang - AI tools: say where a language option such as resultType goes, and note a JMS reply header set by hand From the local-model benchmark of Oct 21-22 2026: - the YAML validator answers an option of a language written next to its short form (expression: {jsonpath: "...", resultType: java.util.Map}) with where it goes, for any language and option - a route that sets or reads a JMS reply header by hand (JMSReplyTo, replyTo, camelJmsReplyTo), with nothing sent InOut, gets a note in the write and validate answers, and the write message points to it - the header checks also run for an endpoint written as the scheme alone (uri: jms, with parameters:) Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m --- .../dsl/jbang/core/commands/ai/AuthoringTools.java | 19 ++++++- .../dsl/jbang/core/commands/ai/HeaderChecks.java | 42 +++++++++++++- .../core/commands/ai/AuthoringToolsEditTest.java | 26 +++++++++ .../commands/ai/SourceValidatorEndpointTest.java | 65 ++++++++++++++++++++++ .../camel/dsl/yaml/validator/SchemaHints.java | 27 +++++++++ .../camel/dsl/yaml/validator/YamlValidator.java | 28 ++++++++++ .../validator/YamlValidatorPropertyHintTest.java | 37 ++++++++++++ 7 files changed, 241 insertions(+), 3 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java index 31174238a18a..ae991eab9d8d 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java @@ -1412,8 +1412,9 @@ public final class AuthoringTools { } /** - * What a Kamelet file does that works but is not right, which does not refuse a write: a camel: dependency its - * template does not use (CAMEL-25403). + * What a file does that works but is not right, which does not refuse a write: a camel: dependency a Kamelet's + * template does not use (CAMEL-25403), a {expression} in the text of an XSLT element, a JMS reply header set by + * hand in a route that sends nothing InOut. */ private static void putFileNotes(JsonObject result, String file, String content) { String name = file != null ? file.toLowerCase(Locale.ROOT) : ""; @@ -1428,6 +1429,12 @@ public final class AuthoringTools { if (!notes.isEmpty()) { result.put("notes", new JsonArray(notes)); } + } else if (name.endsWith(".yaml") || name.endsWith(".yml")) { + // a JMS reply header set by hand, with nothing sent InOut + List<String> notes = HeaderChecks.replyHeaderNotes(content); + if (!notes.isEmpty()) { + result.put("notes", new JsonArray(notes)); + } } } @@ -1678,8 +1685,12 @@ public final class AuthoringTools { result.put("existingProblems", new JsonArray(problemsBefore)); } UnconsumedDirect waiting = null; + boolean replyByHand = false; if (validate) { putFileNotes(result, file, content); + String lower = file.toLowerCase(Locale.ROOT); + replyByHand = (lower.endsWith(".yaml") || lower.endsWith(".yml")) + && !HeaderChecks.replyHeaderNotes(content).isEmpty(); waiting = unconsumedDirect(dir, file, content, ctx.catalog()); if (waiting != null) { JsonArray notes = result.get("notes") instanceof JsonArray existing ? existing : new JsonArray(); @@ -1729,6 +1740,10 @@ public final class AuthoringTools { if (waiting != null) { result.put("message", result.getString("message") + " Note: " + waiting.summary() + " (see notes)."); } + if (replyByHand) { + result.put("message", result.getString("message") + " Note: a JMS reply header is set by hand, and nothing" + + " is sent with pattern: InOut (see notes)."); + } if (!repaired.isEmpty()) { result.put("repaired", new JsonArray(repaired)); result.put("message", "Written with these repairs of the YAML (the file has them now): " diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java index e8294076ad79..e75701a2e1d3 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java @@ -56,7 +56,9 @@ final class HeaderChecks { */ static final Pattern EXCHANGE_CONSTANT_NAME = Pattern.compile("name:\\s*['\"]?Exchange\\.([A-Z][A-Z0-9_]*)"); - static final Pattern SCHEME_IN_URI_PATTERN = Pattern.compile("uri:\\s*\"?([a-zA-Z][a-zA-Z0-9+.-]*):"); + // the scheme of a URI (uri: "jms:queue:x"), or the scheme alone (uri: jms, with the rest under parameters:) + static final Pattern SCHEME_IN_URI_PATTERN + = Pattern.compile("(?m)uri:\\s*\"?([a-zA-Z][a-zA-Z0-9+.-]*)(?::|\"?\\s*$)"); /** * Names a component sets as exchange properties, not headers; the catalog has no metadata for those, so the ones a @@ -115,6 +117,44 @@ final class HeaderChecks { + " reply (the body at the end of the service route); you do not need to set JMSReplyTo or replyTo" + " yourself."; + /** + * A reply header of a JMS request-reply named by hand, in any case: JMSReplyTo, replyTo, jmsReplyTo, + * camelJmsReplyTo. The names that start with Camel and a capital are left to the header check, which reports them. + */ + private static final Pattern REPLY_HEADER_REF = Pattern.compile( + "(?:\\$\\{headers?\\.|headers\\.|headers\\[['\"]|header\\(['\"]|(name):\\s*['\"]?)" + + "((?i:(?:camel)?(?:jms)?replyto))(?![A-Za-z0-9])"); + + /** An endpoint of a JMS component, in a URI (jms:queue:x) or as the scheme alone (uri: jms, with parameters). */ + private static final Pattern JMS_URI = Pattern.compile( + "(?m)uri:\\s*['\"]?(?:jms|sjms2?|activemq6?|amqp)(?::|['\"]?\\s*$)"); + + /** + * Notes for a route that sets or reads a JMS reply header by hand when nothing is sent with InOut (r21 of the + * local-model benchmark: a model set JMSReplyTo in the service route and sent the request one way). A note, not an + * error: a reply queue set by hand for a reply that another route consumes is a design of its own. + */ + static List<String> replyHeaderNotes(String content) { + List<String> notes = new ArrayList<>(); + if (content == null || !JMS_URI.matcher(content).find() || content.contains("InOut")) { + return notes; + } + String[] lines = content.split("\n", -1); + Set<String> seen = new HashSet<>(); + for (int i = 0; i < lines.length; i++) { + Matcher m = REPLY_HEADER_REF.matcher(lines[i]); + while (m.find()) { + String name = m.group(2); + if (name.startsWith("Camel") || (m.group(1) != null && !isHeaderName(lines, i)) || !seen.add(name)) { + continue; + } + notes.add("Line " + (i + 1) + ": " + name + (m.group(1) != null ? " is set by hand" : " is read") + + ", and no message is sent with pattern: InOut. " + JMS_REQUEST_REPLY_HINT); + } + } + return notes; + } + /** * What an invented Camel* header is usually reaching for, keyed on a fragment of its name, and the expression that * answers it. Tried when no real header is close: in a timer-triggered route there is no message source to name, so diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsEditTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsEditTest.java index 2784e3791b58..8d60d33ac71c 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsEditTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsEditTest.java @@ -336,6 +336,32 @@ class AuthoringToolsEditTest { assertThat(whole.getString("message")).doesNotContain("Note:"); } + /** A JMS reply header set by hand, with nothing sent InOut: the write goes through, with a note in the answer. */ + @Test + void aJmsReplyHeaderSetByHandIsANoteOfTheWrite(@TempDir Path dir) { + String content = """ + - route: + from: + uri: jms:queue:stock + steps: + - setHeader: + name: JMSReplyTo + constant: orders + - setBody: + constant: reserved + """; + + JsonObject result = AuthoringTools.writeFile(new ToolContext(), dir, "stock.camel.yaml", content, true); + + assertThat(result.getString("status")).as(result.toJson()).isNotEqualTo("invalid"); + JsonArray notes = (JsonArray) result.get("notes"); + assertThat(notes).hasSize(1); + assertThat(notes.get(0).toString()).startsWith("Line 6: JMSReplyTo is set by hand") + .contains("send with pattern: InOut on the to:"); + assertThat(result.getString("message")) + .endsWith("Note: a JMS reply header is set by hand, and nothing is sent with pattern: InOut (see notes)."); + } + private static JsonObject change(String find, String replace) { JsonObject jo = new JsonObject(); jo.put("find", find); diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java index d3325f4a0527..bd4c6066fd49 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java @@ -607,6 +607,71 @@ class SourceValidatorEndpointTest { .doesNotContain("did you mean")); } + /** + * A reply header set by hand in a route that sends nothing InOut gets a note, in any case of its name (r21 of the + * local-model benchmark: the service route set JMSReplyTo and the request went one way). The endpoints are written + * as the scheme alone with parameters, as well as in a URI. + */ + @Test + void aJmsReplyHeaderSetByHandGetsANote() { + String route = """ + - route: + id: reserve-order + from: + uri: jms + parameters: + destinationType: queue + destinationName: orders + steps: + - toD: + uri: "jms:queue:stock" + - log: + message: "Stock service answered: ${header.camelJmsReplyTo}" + - route: + id: stock-service + from: + uri: jms + parameters: + destinationName: stock + steps: + - setHeader: + name: JMSReplyTo + expression: + simple: "jms:queue:orders" + - setBody: + constant: reserved + """; + assertThat(HeaderChecks.replyHeaderNotes(route)).containsExactly( + "Line 12: camelJmsReplyTo is read, and no message is sent with pattern: InOut. " + + HeaderChecks.JMS_REQUEST_REPLY_HINT, + "Line 21: JMSReplyTo is set by hand, and no message is sent with pattern: InOut. " + + HeaderChecks.JMS_REQUEST_REPLY_HINT); + // with the request sent InOut, the reply is Camel's: no note + assertThat(HeaderChecks.replyHeaderNotes(route.replace("uri: \"jms:queue:stock\"", + "uri: \"jms:queue:stock\"\n pattern: InOut"))).isEmpty(); + // not a JMS route: no note + assertThat(HeaderChecks.replyHeaderNotes(route.replace("uri: jms", "uri: seda").replace("jms:queue:", "seda:"))) + .isEmpty(); + } + + /** The headers of an endpoint written as the scheme alone (uri: jms, with parameters:) are checked too. */ + @Test + void theHeadersOfAnEndpointWrittenAsTheSchemeAloneAreChecked() { + List<String> msgs = SourceValidator.validateKnownHeaders(""" + - route: + from: + uri: jms + parameters: + destinationName: orders + steps: + - log: + message: "${header.CamelJmsDestinationNam}" + """, catalog); + assertThat(msgs).singleElement().satisfies(m -> assertThat(m) + .contains("header CamelJmsDestinationNam is not set by jms") + .contains("did you mean CamelJmsDestination, CamelJmsDestinationName?")); + } + /** The JMS request-reply hint is for JMS only: the reply headers of other components keep their "did you mean". */ @Test void aReplyHeaderOfAnotherComponentGetsDidYouMean() { diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java index 91e852f297a8..031038a77842 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java @@ -156,6 +156,23 @@ final class SchemaHints { return value != null && value.isValueNode() && !value.isNull() ? value.asText() : fallback; } + /** + * The language of the error's node that is written in its short form (jsonpath: "$[...]") next to the unknown + * property, when that property is one of the language's own options (resultType), or null. + */ + private static String shortFormLanguage(Match m) { + JsonNode instance = m.error().getInstanceNode(); + if (instance == null || !instance.isObject() || instance.has("expression")) { + return null; + } + for (String language : m.validator().languageKeys()) { + if (instance.path(language).isTextual() && m.validator().languageOptions(language).contains(m.unknown())) { + return language; + } + } + return null; + } + /** The EIP names a model writes for the exchange properties, and the ones Camel has. */ private static final Map<String, String> EXCHANGE_PROPERTY_EIPS = Map.of( "setExchangeProperty", "setProperty", "setExchangeProperties", "setProperties", @@ -622,6 +639,16 @@ final class SchemaHints { unknownProperty(".*/jsonpath", m -> m.unknown().equalsIgnoreCase("jsonPath") || m.unknown().equals("path"), m -> "the JSONPath text goes under expression: (jsonpath: {expression: \"$[?(@.sku == 'X')]\"," + " resultType: java.util.List})"), + // expression: {jsonpath: "$[...]", resultType: java.util.Map}: an option of the language written next to its + // short form, where the node has no such property; it goes under the language, with the text as expression: + unknownProperty(null, m -> shortFormLanguage(m) != null, + m -> { + String language = shortFormLanguage(m); + return m.unknown() + " is an option of the " + language + " language: write the language as a" + + " map, with the text under expression: and " + m.unknown() + " next to it (" + language + + ": {expression: " + YamlValidator.yamlQuoted(valueOf(m, language, "...")) + ", " + + m.unknown() + ": " + valueOf(m, m.unknown(), "...") + "})"; + }), // onException: {maximumRedeliveries: 3}, deadLetterChannel: {retryAttemptedLogLevel: WARN}: an option of // the redelivery policy written next to it, where the closest name (level) is not what was meant unknownProperty(".*/(onException|" + String.join("|", ROUTE_ERROR_HANDLER_KINDS) + ")", 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 d51ac3e72cb8..dcb624767cf0 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 @@ -873,6 +873,34 @@ public class YamlValidator { return property + ": \"...\""; } + /** The options of a language (resultType, suppressExceptions...) as the schema lists them, or empty. */ + Set<String> languageOptions(String language) { + // the language's definition is referenced from an anyOf entry of ExpressionDefinition (the entry under its + // properties can be an empty placeholder) + JsonNode ref = null; + for (JsonNode value : model.at("/items/definitions/org.apache.camel.model.language.ExpressionDefinition") + .findValues(language)) { + if (value.path("$ref").isTextual()) { + ref = value.path("$ref"); + break; + } + } + JsonNode definition = ref != null ? model.at(ref.asText().substring(1)) : null; + Set<String> answer = new LinkedHashSet<>(); + if (definition == null || definition.isMissingNode()) { + return answer; + } + // the definition is an object, or a oneOf of the short form (a string) and an object + List<JsonNode> shapes = new ArrayList<>(); + shapes.add(definition); + definition.path("oneOf").forEach(shapes::add); + for (JsonNode shape : shapes) { + shape.path("properties").fieldNames().forEachRemaining(answer::add); + } + answer.remove("expression"); + 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 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 index 0e8f8aaa265a..68807cc68354 100644 --- 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 @@ -167,6 +167,43 @@ public class YamlValidatorPropertyHintTest { } } + /** + * An option of a language written next to its short form (a local model in the benchmark of Oct 21-22 2026 wrote + * resultType there, was told only that the property is not defined, and wrote the same file 23 times). + */ + @Test + public void testAnOptionNextToTheShortFormOfALanguageSaysWhereItGoes() throws Exception { + for (YamlValidator v : bothModes()) { + List<Error> errors = v.validate(""" + - route: + from: + uri: direct:get-order + steps: + - setBody: + expression: + jsonpath: "$[?(@.orderId == '${header.id}')]" + resultType: java.util.Map + """); + assertThat(errors).extracting(Error::getMessage) + .anyMatch(m -> m.contains("resultType is an option of the jsonpath language: write the language as a" + + " map, with the text under expression: and resultType next to it" + + " (jsonpath: {expression: \"$[?(@.orderId == '${header.id}')]\"," + + " resultType: java.util.Map})")); + } + // the full form is valid + assertThat(validator.validate(""" + - route: + from: + uri: direct:get-order + steps: + - setBody: + expression: + jsonpath: + expression: "$[?(@.orderId == '${header.id}')]" + resultType: java.util.Map + """)).isEmpty(); + } + /** CAMEL-24888: a double-quoted value that is never closed is named, instead of the parser's block-end message. */ @Test public void testAnUnclosedQuoteIsNamed() throws Exception {
