This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 29fac56707ec CAMEL-24888: the validator says the shape for the seven
mistakes of the HTTP rungs
29fac56707ec is described below
commit 29fac56707ec2c466a29bcab1152622dad3bffb5
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Sep 22 12:05:07 2026 +0200
CAMEL-24888: the validator says the shape for the seven mistakes of the
HTTP rungs
On the HTTP rungs of the examples ladder (rest, http, rest-openapi) the
local
model failed 22 of 32 steps in the camel-jbang-mcp stepwise benchmark, and
its
refused writes cluster on seven shapes the validator did not name a fix for.
Each now gets a hint that states the form to write:
- constant: null to clear the body -> setBody with simple ${null}
- setExchangeProperty (and siblings) -> the EIP is setProperty
- a double-quoted value never closed -> "line N: the value opens a double
quote and never closes it", from the Jackson and snakeyaml message alike
- toD: {options: ...} -> options go under parameters: like to:
- library: jackson -> the library name is case sensitive, taken from the
schema's own enum list so each data format names its own entries
- jsonpath: {jsonPath: ...} -> the text goes under expression:
- rest-openapi path/pathParameters/queryParameters option -> the parameter
comes from a header of the same name
Covered by YamlValidatorPropertyHintTest (both schema modes) and
SourceValidatorPlaceholderTest.
Closes #26732
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---
.../dsl/jbang/core/commands/ai/EndpointChecks.java | 10 +++
.../ai/SourceValidatorPlaceholderTest.java | 27 ++++++++
.../camel/dsl/yaml/validator/SchemaHints.java | 38 +++++++++++
.../camel/dsl/yaml/validator/YamlValidator.java | 30 +++++++++
.../validator/YamlValidatorPropertyHintTest.java | 77 ++++++++++++++++++++++
5 files changed, 182 insertions(+)
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
index 1a24f34daa9f..0163b9bb44a4 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
@@ -282,6 +282,16 @@ final class EndpointChecks {
/** Options models write that the component does not have, and what the
component does instead. */
static final Map<String, String> INVENTED_OPTIONS = Map.ofEntries(
+ // CAMEL-24888: the path parameters of an OpenAPI operation are
headers of the same name
+ Map.entry("rest-openapi:path",
+ "a path parameter of the operation, {sku} in /stock/{sku},
comes from a header of the same name: add"
+ + " setHeader: {name: sku, ...}
before the call, the operation's path is in the contract"),
+ Map.entry("rest-openapi:pathParameters",
+ "a path parameter of the operation comes from a header of
the same name: add setHeader: {name: sku,"
+ + " ...} before the
call"),
+ Map.entry("rest-openapi:queryParameters",
+ "a query parameter of the operation comes from a header of
the same name: add setHeader: {name: page,"
+ + " ...} before the
call"),
Map.entry("file:mkdir", "directories are created by default
(autoCreate=true); remove the option"),
Map.entry("file:createDirectory", "directories are created by
default (autoCreate=true); remove the option"),
Map.entry("file:overwrite", "an existing file is overridden by
default (fileExist=Override); remove the option"),
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorPlaceholderTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorPlaceholderTest.java
index 36822c18c4fd..4019b2a2dcfe 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorPlaceholderTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorPlaceholderTest.java
@@ -32,6 +32,33 @@ class SourceValidatorPlaceholderTest {
private static final CamelCatalog catalog = new DefaultCamelCatalog();
+ /** CAMEL-24888: a path parameter of an OpenAPI operation is a header, not
an endpoint option. */
+ @Test
+ void aRestOpenApiPathOptionSaysTheParameterIsAHeader() {
+ String yaml = """
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - to:
+ uri: rest-openapi
+ parameters:
+ specificationUri: stock-api.json
+ operationId: reserveStock
+ path: "sku=${exchangeProperty.sku}"
+ """;
+ List<String> errors = SourceValidator.validateYamlEndpoints(yaml,
catalog);
+ assertThat(errors).anyMatch(e -> e.contains("rest-openapi: Unknown
option 'path'")
+ && e.contains("comes from a header of the same name: add
setHeader: {name: sku"));
+ // the sibling spellings say the same
+ for (String option : List.of("pathParameters", "queryParameters")) {
+ List<String> more
+ =
SourceValidator.validateYamlEndpoints(yaml.replace("path: \"sku=", option + ":
\"sku="), catalog);
+ assertThat(more).anyMatch(e -> e.contains("rest-openapi: Unknown
option '" + option + "'")
+ && e.contains("comes from a header of the same name: add
setHeader: {name: "));
+ }
+ }
+
@Test
void aSimplePlaceholderInAnEndpointOptionSaysToWriteAPropertyPlaceholder()
{
String yaml = """
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 9f9acbc4b47f..a8b2e96b09fe 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
@@ -116,6 +116,12 @@ final class SchemaHints {
private static final Predicate<Match> ANY = m -> true;
+ /** 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",
+ "removeExchangeProperty", "removeProperty",
"removeExchangeProperties", "removeProperties",
+ "setExchangeVariable", "setVariable", "setExchangeVariables",
"setVariables");
+
private static final Set<String> ROUTE_ERROR_HANDLER_KINDS
= Set.of("noErrorHandler", "deadLetterChannel",
"defaultErrorHandler", "springTransactionErrorHandler",
"jtaTransactionErrorHandler", "refErrorHandler");
@@ -230,6 +236,27 @@ final class SchemaHints {
return eip + " holds its EIPs under steps: " + eip +
": {steps: [- log: \"...\"]}"
+ (eip.equals("doCatch") ? ", each - doCatch:
item with exception: and steps:" : "");
}),
+ // setBody: {constant: null} to clear the body before a GET:
constant is a text (CAMEL-24888)
+ append("type", ".*/constant(/expression)?", m ->
m.message().contains("null found"),
+ m -> "constant is a text; to set an empty body (a GET
sends none) write setBody: {simple:"
+ + " {expression: \"${null}\"}}"),
+ // library: jackson: the enumeration is case sensitive; the name
to write is the entry of this data
+ // format's enumeration (json, avro, protobuf, yaml each have
their own) that matches ignoring case
+ append("enum", ".*/library", ANY,
+ m -> {
+ JsonNode instance = m.error().getInstanceNode();
+ String written = instance != null &&
instance.isValueNode() ? instance.asText() : "";
+ String list = between(m.message(), "[", "]");
+ String match = null;
+ for (String entry : (list == null ? "" :
list).split(",")) {
+ String name = entry.trim().replace("\"", "");
+ if (!name.isEmpty() &&
name.equalsIgnoreCase(written)) {
+ match = name;
+ }
+ }
+ return "the library name is case sensitive"
+ + (match != null ? ": write library: " + match
: ", write it as listed");
+ }),
append("type", "/?", m -> m.message().contains("array expected"),
m -> "a Camel YAML file is a list of entries, each
starting with \"- \": - route:, - from:, - beans:,"
+ " - rest:, - onException:"),
@@ -384,6 +411,17 @@ final class SchemaHints {
m -> "step is the Step EIP, a named group: its EIPs go in
its steps: list (step: {id: ..., steps: [-"
+ " setHeader: ...]})",
"additionalProperties", "additionalProperties"),
+ // CAMEL-24888 (the HTTP rungs of the examples ladder): the shapes
a model writes for the EIPs of a REST app
+ unknownProperty(null, m ->
EXCHANGE_PROPERTY_EIPS.containsKey(m.unknown()),
+ m -> "the EIP is " +
EXCHANGE_PROPERTY_EIPS.get(m.unknown()) + ": write - "
+ + EXCHANGE_PROPERTY_EIPS.get(m.unknown()) + ": {name:
..., expression: {simple: {expression:"
+ + " \"...\"}}} (an exchange property is read back as
${exchangeProperty.name})"),
+ unknownProperty(".*/toD", m -> m.unknown().equals("options") ||
m.unknown().equals("params"),
+ m -> "toD takes its options like to: under parameters:
(toD: {uri: \"http://...\", parameters:"
+ + " {throwExceptionOnFailure: false}}), or in the uri
after ?"),
+ 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})"),
unknownProperty(null,
m -> YamlValidator.closest(m.unknown(),
m.validator().knownProperties(m.schemaLocation())) != null,
m -> "did you mean '"
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 128dc98b56ef..2b47f748c2dc 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
@@ -206,6 +206,14 @@ public class YamlValidator {
.build();
}
}
+ // a double-quoted value that never closes: the parser swallows
the next lines and gives up further down
+ // (CAMEL-24888); the line that opened the quote is within the few
lines above the reported one
+ for (int l = last; l >= 1 && l > last - 8; l--) {
+ Error unclosed = unclosedQuote(new Mark(l, 1), lines);
+ if (unclosed != null) {
+ return unclosed;
+ }
+ }
Error marked = indentationError(msg, lines);
return marked != null ? marked : plain;
}
@@ -253,6 +261,28 @@ public class YamlValidator {
if (msg.contains("found unknown escape character")) {
return unknownEscape(msg, problem, lines);
}
+ if (msg.contains("expected <block end>, but found '<scalar>'") &&
!marks.isEmpty()) {
+ return unclosedQuote(marks.get(0), lines);
+ }
+ return null;
+ }
+
+ /**
+ * CAMEL-24888: {@code expression: "$[?(@.sku == '${header.sku}')]} with
no closing quote: the parser swallows the
+ * following lines into the value and gives up at the next key. Says which
line opened the quote.
+ */
+ static Error unclosedQuote(Mark start, String[] lines) {
+ if (start.line() < 1 || start.line() > lines.length) {
+ return null;
+ }
+ String line = lines[start.line() - 1];
+ int colon = line.indexOf(':');
+ String value = colon >= 0 ? line.substring(colon + 1).trim() :
line.trim();
+ long quotes = value.chars().filter(c -> c == '"').count() -
value.split("\\\\\"", -1).length + 1;
+ if (value.startsWith("\"") && quotes % 2 == 1) {
+ return hint("line " + start.line() + ": the value opens a double
quote and never closes it: end it with"
+ + " a \" after the last character (" + value + "\")");
+ }
return null;
}
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 a5d9b671afa6..03d33edc27a6 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
@@ -90,6 +90,83 @@ public class YamlValidatorPropertyHintTest {
}
}
+ /** CAMEL-24888: the shapes the local model wrote on the HTTP rungs, each
with the form to write. */
+ @Test
+ public void testHttpRungShapesGetTheForm() throws Exception {
+ for (YamlValidator v : bothModes()) {
+ List<Error> errors = v.validate("""
+ - route:
+ from:
+ uri: file:orders
+ steps:
+ - setExchangeProperty:
+ name: orderId
+ expression:
+ simple:
+ expression: "${body[orderId]}"
+ - setBody:
+ expression:
+ constant: null
+ - marshal:
+ json:
+ library: jackson
+ """);
+ assertThat(errors).extracting(Error::getMessage)
+ .anyMatch(m -> m.contains("setExchangeProperty") &&
m.contains("the EIP is setProperty"))
+ .anyMatch(m -> m.contains("constant is a text") &&
m.contains("simple: {expression: \"${null}\"}"))
+ .anyMatch(m -> m.contains("the library name is case
sensitive: write library: Jackson"));
+ // the name comes from the data format's own enumeration, not from
json's
+ errors = v.validate("""
+ - route:
+ from:
+ uri: file:orders
+ steps:
+ - marshal:
+ avro:
+ library: apacheavro
+ """);
+ assertThat(errors).extracting(Error::getMessage)
+ .anyMatch(m -> m.contains("the library name is case
sensitive: write library: ApacheAvro")
+ && !m.contains("Gson"));
+ errors = v.validate("""
+ - route:
+ from:
+ uri: file:orders
+ steps:
+ - toD:
+ uri:
"http://localhost:8080/stock/${exchangeProperty.sku}"
+ options:
+ throwExceptionOnFailure: false
+ - setBody:
+ expression:
+ jsonpath:
+ jsonPath: "$[?(@.sku == 'X')]"
+ """);
+ assertThat(errors).extracting(Error::getMessage)
+ .anyMatch(m -> m.contains("toD takes its options like to:
under parameters:"))
+ .anyMatch(m -> m.contains("the JSONPath text goes under
expression:"));
+ }
+ }
+
+ /** 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 {
+ List<Error> errors = validator.validate("""
+ - route:
+ from:
+ uri: direct:a
+ steps:
+ - setBody:
+ expression:
+ jsonpath:
+ expression: "$[?(@.sku == '${header.sku}')]
+ resultType: java.util.List
+ - log: "done"
+ """);
+ assertThat(errors).extracting(Error::getMessage)
+ .anyMatch(m -> m.startsWith("line 8: the value opens a double
quote and never closes it"));
+ }
+
@Test
public void testPollEnrichWithAUriSaysItIsAnExpression() throws Exception {
for (YamlValidator v : bothModes()) {