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 14203c663d84 CAMEL-25762: camel-jbang - the validator learns the
OpenAPI operations and the REST binding mode beside a route (#28058)
14203c663d84 is described below
commit 14203c663d842e5c84273ec5b8fae6febcaa7cf5
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun Oct 18 16:01:14 2026 +0200
CAMEL-25762: camel-jbang - the validator learns the OpenAPI operations and
the REST binding mode beside a route (#28058)
The parsed-data check of CAMEL-24844 missed the cases whose facts are not
in the route file. camel-jbang now reads them from the files beside the
route and passes them to the YAML validator (ProjectFacts), as it already
passed the operations without a body: the operations of the OpenAPI
specifications the file uses (contract first, and rest-openapi calls),
beside the route or in src/main/resources, and the REST binding mode the
project's properties set.
The validator then reports a rest-openapi call to a POST, PUT or PATCH
operation that is given parsed data, and the end of a REST route (a verb's
route, or a contract-first direct:<operationId> route) that is parsed
data; only when no binding mode in the file or the properties can write
the data, and nothing when the facts are not known. A split with jsonpath
whose steps read fields of each element counts as giving data too.
On the routes the local model wrote in the r17 benchmark series, 10 of the
14 runs that failed on parsed data are now reported before running (2
before); no report on 1101 human-written route files.
Signed-off-by: Claus Ibsen <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
.../dsl/jbang/core/commands/ai/OpenApiVerbs.java | 96 ++++++--
.../jbang/core/commands/ai/SourceValidator.java | 23 +-
.../dsl/jbang/core/commands/ai/ValidatorFacts.java | 103 ++++++++
.../jbang/core/commands/ai/ValidatorFactsTest.java | 180 ++++++++++++++
.../camel/dsl/yaml/validator/ProjectFacts.java | 58 +++++
.../camel/dsl/yaml/validator/TextBodyFlow.java | 273 +++++++++++++++++----
.../camel/dsl/yaml/validator/YamlValidator.java | 17 +-
.../camel/dsl/yaml/validator/TextBodyFlowTest.java | 171 +++++++++++++
8 files changed, 847 insertions(+), 74 deletions(-)
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/OpenApiVerbs.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/OpenApiVerbs.java
index 5880bdf6428d..3c194461b574 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/OpenApiVerbs.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/OpenApiVerbs.java
@@ -18,12 +18,16 @@ package org.apache.camel.dsl.jbang.core.commands.ai;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import org.apache.camel.dsl.yaml.validator.ProjectFacts;
import org.apache.camel.util.json.Jsoner;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
@@ -44,13 +48,75 @@ public final class OpenApiVerbs {
private static final Pattern SPECIFICATION = Pattern.compile(
"openApi:\\s*\\n\\s*(?:[a-zA-Z]+:[^\\n]*\\n\\s*)*?specification:\\s*[\"']?([^\"'\\s]+)[\"']?");
+ /** rest-openapi:spec#operationId or rest-openapi://spec#operationId: the
specification, which may be empty. */
+ private static final Pattern REST_OPENAPI_CALL =
Pattern.compile("rest-openapi:(?://)?([^\\s\"'?#]*)#");
+
+ /** The specificationUri option of a rest-openapi call. */
+ private static final Pattern SPECIFICATION_URI =
Pattern.compile("specificationUri:\\s*[\"']?([^\"'\\s]+)[\"']?");
+
private OpenApiVerbs() {
}
+ /**
+ * The operations of the OpenAPI specifications a file uses, by
specification as {@link ProjectFacts#specName} names
+ * it: the verb of each operationId, in lower case. The specifications a
contract-first binding names, and the ones
+ * of rest-openapi calls (the default openapi.json when a call names
none), beside the route or in
+ * src/main/resources. A specification that cannot be read is left out -
nothing is guessed (CAMEL-25762).
+ */
+ public static Map<String, Map<String, String>> operations(String content,
Path directory) {
+ Map<String, Map<String, String>> answer = new LinkedHashMap<>();
+ if (content == null || directory == null) {
+ return answer;
+ }
+ Set<String> specs = new LinkedHashSet<>();
+ for (Pattern pattern : List.of(SPECIFICATION, REST_OPENAPI_CALL,
SPECIFICATION_URI)) {
+ Matcher m = pattern.matcher(content);
+ while (m.find()) {
+ specs.add(ProjectFacts.specName(m.group(1)));
+ }
+ }
+ if (content.contains("rest-openapi")) {
+ specs.add(ProjectFacts.specName(null));
+ }
+ for (String spec : specs) {
+ Map<?, ?> paths = spec.contains("://") || spec.contains("{{") ?
null : paths(directory, spec);
+ if (paths == null) {
+ continue;
+ }
+ Map<String, String> verbs = new LinkedHashMap<>();
+ for (Object path : paths.values()) {
+ if (path instanceof Map<?, ?> operations) {
+ for (Map.Entry<?, ?> operation : operations.entrySet()) {
+ if (operation.getValue() instanceof Map<?, ?> details
&& details.get("operationId") != null) {
+
verbs.put(String.valueOf(details.get("operationId")),
+
String.valueOf(operation.getKey()).toLowerCase(Locale.ROOT));
+ }
+ }
+ }
+ }
+ answer.put(spec, verbs);
+ }
+ return answer;
+ }
+
+ /** The paths of a specification beside the route, or in
src/main/resources; null when it cannot be read. */
+ private static Map<?, ?> paths(Path directory, String file) {
+ Path p = directory.resolve(file);
+ if (!Files.isRegularFile(p)) {
+ p = directory.resolve("src/main/resources").resolve(file);
+ }
+ try {
+ return Files.isRegularFile(p) ? paths(Files.readString(p)) : null;
+ } catch (Exception e) {
+ // an unreadable specification says nothing
+ return null;
+ }
+ }
+
/**
* The {@code direct:} endpoints of the operations that carry no body, for
a file that binds to an OpenAPI
- * specification in the given directory. Empty when the file binds to
none, or the specification cannot be read -
- * nothing is guessed.
+ * specification beside it or in src/main/resources, found as {@link
#operations} finds it. Empty when the file
+ * binds to none, or the specification cannot be read - nothing is guessed.
*/
public static Set<String> bodylessEndpoints(String content, Path
directory) {
Set<String> answer = new LinkedHashSet<>();
@@ -59,29 +125,21 @@ public final class OpenApiVerbs {
}
Matcher m = SPECIFICATION.matcher(content);
while (m.find()) {
- Path spec = directory.resolve(m.group(1));
- if (!Files.isRegularFile(spec)) {
+ Map<?, ?> paths = paths(directory,
ProjectFacts.specName(m.group(1)));
+ if (paths == null) {
continue;
}
- try {
- Map<?, ?> paths = paths(Files.readString(spec));
- if (paths == null) {
+ for (Object path : paths.values()) {
+ if (!(path instanceof Map<?, ?> operations)) {
continue;
}
- for (Object path : paths.values()) {
- if (!(path instanceof Map<?, ?> operations)) {
- continue;
- }
- for (Map.Entry<?, ?> operation : operations.entrySet()) {
- String verb =
String.valueOf(operation.getKey()).toLowerCase(java.util.Locale.ROOT);
- if (WITHOUT_BODY.contains(verb) &&
operation.getValue() instanceof Map<?, ?> details
- && details.get("operationId") != null) {
- answer.add("direct:" + details.get("operationId"));
- }
+ for (Map.Entry<?, ?> operation : operations.entrySet()) {
+ String verb =
String.valueOf(operation.getKey()).toLowerCase(Locale.ROOT);
+ if (WITHOUT_BODY.contains(verb) && operation.getValue()
instanceof Map<?, ?> details
+ && details.get("operationId") != null) {
+ answer.add("direct:" + details.get("operationId"));
}
}
- } catch (Exception e) {
- // Catching Exception handles unreadable and unparseable JSON
or YAML specifications.
}
}
return answer;
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
index 19001ee8989f..6378d0af3b9e 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
@@ -36,6 +36,7 @@ import com.networknt.schema.Error;
import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.catalog.DefaultCamelCatalog;
import org.apache.camel.dsl.jbang.core.common.CatalogLoader;
+import org.apache.camel.dsl.yaml.validator.ProjectFacts;
import org.apache.camel.dsl.yaml.validator.YamlPointerLines;
import org.apache.camel.dsl.yaml.validator.YamlValidator;
import org.snakeyaml.engine.v2.nodes.Node;
@@ -118,9 +119,10 @@ public final class SourceValidator {
String name = fileName == null ? "" :
fileName.toLowerCase(Locale.ROOT);
if (name.endsWith(".yaml") || name.endsWith(".yml")) {
// a rest binding to an OpenAPI specification hides the verb, and
the specification is a file beside the
- // route: read it, so that a GET operation is known to carry no
body (CAMEL-24844)
+ // route: read it, so that a GET operation is known to carry no
body (CAMEL-24844), and with the REST binding
+ // mode of the properties, whether a Map sent or returned over
REST is written for the route (CAMEL-25762)
List<String> msgs = validateCamelYaml(content, catalog,
schemaValidator,
- directory != null ?
OpenApiVerbs.bodylessEndpoints(content, directory) : Set.of());
+ ValidatorFacts.read(content, directory));
if (name.endsWith(".kamelet.yaml") ||
name.endsWith(".kamelet.yml")) {
// the shape of a Kamelet file, which the schema of a route
file does not know
List<String> shape =
KameletChecks.validateKameletFile(content);
@@ -234,11 +236,20 @@ public final class SourceValidator {
*/
public static List<String> validateCamelYaml(
String content, CamelCatalog catalog, YamlValidator
schemaValidator, Set<String> bodylessEndpoints) {
+ return validateCamelYaml(content, catalog, schemaValidator, new
ProjectFacts(bodylessEndpoints, Map.of(), null));
+ }
+
+ /**
+ * As {@link #validateCamelYaml(String, CamelCatalog, YamlValidator)} with
what the files beside the route say: the
+ * OpenAPI operations the file uses and the REST binding mode of the
project (CAMEL-25762).
+ */
+ public static List<String> validateCamelYaml(
+ String content, CamelCatalog catalog, YamlValidator
schemaValidator, ProjectFacts facts) {
List<String> msgs = new ArrayList<>();
if (content == null || content.isBlank()) {
return msgs;
}
- if (validateYamlSchema(content, catalog, schemaValidator,
bodylessEndpoints, msgs)) {
+ if (validateYamlSchema(content, catalog, schemaValidator, facts,
msgs)) {
msgs.addAll(validateYamlCatalog(content, catalog));
}
return msgs;
@@ -261,11 +272,11 @@ public final class SourceValidator {
/** Adds the schema errors to msgs; false when the YAML could not be
checked at all (no schema, not YAML). */
private static boolean validateYamlSchema(
String content, CamelCatalog catalog, YamlValidator
schemaValidator, List<String> msgs) {
- return validateYamlSchema(content, catalog, schemaValidator, Set.of(),
msgs);
+ return validateYamlSchema(content, catalog, schemaValidator,
ProjectFacts.NONE, msgs);
}
private static boolean validateYamlSchema(
- String content, CamelCatalog catalog, YamlValidator
schemaValidator, Set<String> bodylessEndpoints,
+ String content, CamelCatalog catalog, YamlValidator
schemaValidator, ProjectFacts facts,
List<String> msgs) {
YamlValidator validator;
try {
@@ -276,7 +287,7 @@ public final class SourceValidator {
return false;
}
try {
- msgs.addAll(formatSchemaErrors(validator.validate(content,
bodylessEndpoints), content));
+ msgs.addAll(formatSchemaErrors(validator.validate(content, facts),
content));
return true;
} catch (Exception e) {
msgs.add("Invalid YAML: " + e.getMessage());
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ValidatorFacts.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ValidatorFacts.java
new file mode 100644
index 000000000000..601e7837a51a
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ValidatorFacts.java
@@ -0,0 +1,103 @@
+/*
+ * 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.jbang.core.commands.ai;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+import org.apache.camel.dsl.yaml.validator.ProjectFacts;
+
+/**
+ * What the YAML validator cannot read from a route file, read from the files
beside it (CAMEL-25762): the operations of
+ * the OpenAPI specifications the file uses, and the REST binding mode the
project's properties set.
+ */
+final class ValidatorFacts {
+
+ /** The configuration files Camel reads, with their profiles:
application.properties, application-dev.yaml... */
+ private static final Pattern CONFIGURATION =
Pattern.compile("application(-[\\w.-]+)?\\.(properties|ya?ml)");
+
+ /** camel.rest.bindingMode = json, camel.rest.binding-mode: json, or
binding-mode: json nested in YAML. */
+ private static final Pattern BINDING_MODE = Pattern.compile(
+ "(?i)(?:^|[.\\s])binding-?mode\\s*[=:]\\s*[\"']?([a-z_]+)");
+
+ /** The binding modes that write a Map or a List for the route. */
+ private static final Set<String> BINDS = Set.of("auto", "json", "xml",
"json_xml");
+
+ private ValidatorFacts() {
+ }
+
+ /** The facts for a file in a directory, or nothing when there is no
directory. */
+ static ProjectFacts read(String content, Path directory) {
+ if (content == null || directory == null) {
+ return ProjectFacts.NONE;
+ }
+ return new ProjectFacts(
+ OpenApiVerbs.bodylessEndpoints(content, directory),
OpenApiVerbs.operations(content, directory),
+ restBindingMode(directory));
+ }
+
+ /**
+ * The REST binding mode the configuration beside the route (or in
src/main/resources) sets: off when it sets none.
+ * A binding that writes data in any of the files, a profile among them,
wins: a route that only some profiles bind
+ * is not reported.
+ */
+ static String restBindingMode(Path directory) {
+ String answer = "off";
+ for (Path dir : List.of(directory,
directory.resolve("src/main/resources"))) {
+ if (!Files.isDirectory(dir)) {
+ continue;
+ }
+ List<Path> files;
+ try (Stream<Path> list = Files.list(dir)) {
+ files = list.filter(f ->
CONFIGURATION.matcher(f.getFileName().toString()).matches()).toList();
+ } catch (IOException e) {
+ continue;
+ }
+ for (Path file : files) {
+ String text;
+ try {
+ text = Files.readString(file);
+ } catch (IOException e) {
+ continue;
+ }
+ for (String line : text.split("\n")) {
+ String l = line.strip();
+ String lower = l.toLowerCase(Locale.ROOT);
+ if (l.startsWith("#") || (!lower.contains("rest") &&
!lower.startsWith("binding"))) {
+ continue;
+ }
+ Matcher m = BINDING_MODE.matcher(l);
+ if (m.find()) {
+ String mode = m.group(1).toLowerCase(Locale.ROOT);
+ if (BINDS.contains(mode)) {
+ return mode;
+ }
+ answer = mode;
+ }
+ }
+ }
+ }
+ return answer;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ValidatorFactsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ValidatorFactsTest.java
new file mode 100644
index 000000000000..f20581ceb595
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ValidatorFactsTest.java
@@ -0,0 +1,180 @@
+/*
+ * 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.jbang.core.commands.ai;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-25762: the facts the validator cannot read from a route file - the
verbs of the OpenAPI operations it uses, and
+ * the REST binding mode of the project - are read from the files beside it.
+ */
+class ValidatorFactsTest {
+
+ private static final String STOCK_API = """
+ {
+ "openapi": "3.0.2",
+ "paths": {
+ "/stock/{sku}": { "get": { "operationId": "getStock",
"responses": { "200": { "description": "ok" } } } },
+ "/reservations": { "post": { "operationId": "reserveStock",
"responses": { "201": { "description": "ok" } } } }
+ }
+ }
+ """;
+
+ private static final String ORDERS_API = """
+ openapi: 3.0.2
+ paths:
+ /orders:
+ post:
+ operationId: createOrder
+ responses:
+ '201':
+ description: created
+ """;
+
+ private static final String RESERVE = """
+ - route:
+ id: reserve
+ from:
+ uri: file:orders
+ steps:
+ - unmarshal:
+ json: {}
+ - to:
+ uri: rest-openapi:stock-api.json#reserveStock
+ parameters:
+ host: http://localhost:8080
+ """;
+
+ @Test
+ void theOperationsOfTheSpecificationsTheFileUses(@TempDir Path dir) throws
Exception {
+ Files.writeString(dir.resolve("stock-api.json"), STOCK_API);
+ Files.createDirectories(dir.resolve("src/main/resources"));
+ Files.writeString(dir.resolve("src/main/resources/orders-api.yaml"),
ORDERS_API);
+ String content = """
+ - rest:
+ openApi:
+ specification: stock-api.json
+ - route:
+ from:
+ uri: direct:getStock
+ steps:
+ - to: rest-openapi:orders-api.yaml#createOrder
+ """;
+
+ Map<String, Map<String, String>> operations =
OpenApiVerbs.operations(content, dir);
+
+ assertThat(operations).containsOnlyKeys("stock-api.json",
"orders-api.yaml");
+ assertThat(operations.get("stock-api.json")).containsEntry("getStock",
"get").containsEntry("reserveStock", "post");
+
assertThat(operations.get("orders-api.yaml")).containsEntry("createOrder",
"post");
+ }
+
+ @Test
+ void theOperationsWithoutABodyComeFromTheSameSpecification(@TempDir Path
dir) throws Exception {
+ // a Maven project keeps the specification in src/main/resources: both
facts find it there
+ Files.createDirectories(dir.resolve("src/main/resources"));
+ Files.writeString(dir.resolve("src/main/resources/stock-api.json"),
STOCK_API);
+ String content = "- rest:\n openApi:\n specification:
stock-api.json\n";
+
+ assertThat(OpenApiVerbs.bodylessEndpoints(content,
dir)).containsExactly("direct:getStock");
+ assertThat(OpenApiVerbs.operations(content,
dir)).containsOnlyKeys("stock-api.json");
+ }
+
+ @Test
+ void aCallThatNamesNoSpecificationUsesTheDefault(@TempDir Path dir) throws
Exception {
+ Files.writeString(dir.resolve("openapi.json"), STOCK_API);
+
+ assertThat(OpenApiVerbs.operations("- to:
rest-openapi:reserveStock\n", dir)).containsOnlyKeys("openapi.json");
+ assertThat(OpenApiVerbs.operations("- to: direct:x\n", dir)).isEmpty();
+ }
+
+ @Test
+ void theRestBindingModeOfTheProperties(@TempDir Path dir) throws Exception
{
+ assertThat(ValidatorFacts.restBindingMode(dir)).isEqualTo("off");
+
+ Files.writeString(dir.resolve("application.properties"), "#
camel.rest.bindingMode = json\ncamel.server.port=8080\n");
+ assertThat(ValidatorFacts.restBindingMode(dir)).as("a comment sets
nothing").isEqualTo("off");
+
+ Files.writeString(dir.resolve("application.properties"),
"camel.rest.bindingMode = off\n");
+ assertThat(ValidatorFacts.restBindingMode(dir)).isEqualTo("off");
+
+ // a profile that binds wins: a route only some profiles bind is not
reported
+ Files.writeString(dir.resolve("application-dev.properties"),
"camel.rest.binding-mode=auto\n");
+ assertThat(ValidatorFacts.restBindingMode(dir)).isEqualTo("auto");
+ }
+
+ @Test
+ void theRestBindingModeOfYamlConfiguration(@TempDir Path dir) throws
Exception {
+ Files.createDirectories(dir.resolve("src/main/resources"));
+ Files.writeString(dir.resolve("src/main/resources/application.yaml"),
"""
+ camel:
+ rest:
+ binding-mode: json
+ """);
+ assertThat(ValidatorFacts.restBindingMode(dir)).isEqualTo("json");
+ }
+
+ @Test
+ void parsedDataSentByRestOpenApi(@TempDir Path dir) throws Exception {
+ Files.writeString(dir.resolve("stock-api.json"), STOCK_API);
+
+ List<String> msgs = SourceValidator.validate("orders.camel.yaml",
RESERVE, new DefaultCamelCatalog(), null, dir);
+ assertThat(msgs).anyMatch(m -> m.contains("to: rest-openapi sends the
body as the request of the operation"
+ + " reserveStock (POST in
stock-api.json)"));
+
+ // the binding of the properties writes the Map as JSON
+ Files.writeString(dir.resolve("application.properties"),
"camel.rest.bindingMode=json\n");
+ assertThat(SourceValidator.validate("orders.camel.yaml", RESERVE, new
DefaultCamelCatalog(), null, dir))
+ .noneMatch(m -> m.contains("rest-openapi sends the body"));
+ }
+
+ @Test
+ void parsedDataAsTheReplyOfAContractFirstOperation(@TempDir Path dir)
throws Exception {
+ Files.writeString(dir.resolve("stock-api.json"), STOCK_API);
+ Files.writeString(dir.resolve("stock.json"), "[{\"sku\": \"A1\",
\"qty\": 3}]");
+ String content = """
+ - rest:
+ openApi:
+ specification: stock-api.json
+ - route:
+ id: getStock
+ from:
+ uri: direct:getStock
+ steps:
+ - setBody:
+ constant: resource:file:stock.json
+ - unmarshal:
+ json: {}
+ - setBody:
+ groovy: body.find { it.sku == headers.sku }
+ """;
+
+ List<String> msgs = SourceValidator.validate("stock.camel.yaml",
content, new DefaultCamelCatalog(), null, dir);
+ assertThat(msgs).anyMatch(m -> m.contains("the body at the end of the
route is the reply to the REST call"));
+ // without a directory nothing is known about the operations or the
binding: nothing is said
+ assertThat(SourceValidator.validate("stock.camel.yaml", content, new
DefaultCamelCatalog(), null, null))
+ .noneMatch(m -> m.contains("reply to the REST call"));
+ }
+}
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/ProjectFacts.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/ProjectFacts.java
new file mode 100644
index 000000000000..865cbfe5c378
--- /dev/null
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/ProjectFacts.java
@@ -0,0 +1,58 @@
+/*
+ * 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.Map;
+import java.util.Set;
+
+/**
+ * What the validator cannot read from a route file, read by the caller from
the files beside it (CAMEL-25762): the
+ * OpenAPI operations the routes use, whose verb decides whether a call sends
the body, and the REST binding mode of the
+ * project, which decides whether the binding writes a Map for the route.
+ *
+ * @param bodylessEndpoints the {@code direct:} endpoints of the operations of
a contract-first binding whose request
+ * carries no body (CAMEL-24844)
+ * @param operations per specification, keyed as {@link
#specName(String)} names it: the HTTP verb of each
+ * operationId, in lower case
+ * @param restBindingMode the binding mode the project's properties set for
REST ({@code off} when they set none), or
+ * null when not known: then nothing is said that a
binding could change
+ */
+public record ProjectFacts(Set<String> bodylessEndpoints, Map<String,
Map<String, String>> operations,
+ String restBindingMode) {
+
+ /** Nothing known about the project. */
+ public static final ProjectFacts NONE = new ProjectFacts(Set.of(),
Map.of(), null);
+
+ public ProjectFacts {
+ bodylessEndpoints = bodylessEndpoints != null ? bodylessEndpoints :
Set.of();
+ operations = operations != null ? operations : Map.of();
+ }
+
+ /**
+ * A specification as the routes name it, as {@link #operations()} keys
it: the file without {@code file:} or
+ * {@code classpath:}, and {@code openapi.json}, the default of
rest-openapi, when none is named.
+ */
+ public static String specName(String specification) {
+ String file = specification == null || specification.isBlank() ?
"openapi.json" : specification.trim();
+ for (String prefix : new String[] { "file:", "classpath:" }) {
+ if (file.startsWith(prefix)) {
+ file = file.substring(prefix.length());
+ }
+ }
+ return file;
+ }
+}
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/TextBodyFlow.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/TextBodyFlow.java
index eb68cc96ce46..241d056acf53 100644
---
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/TextBodyFlow.java
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/TextBodyFlow.java
@@ -98,6 +98,9 @@ final class TextBodyFlow {
/** ${body}, ${body[lines]} or ${body.lines}: the body, or a field of it.
*/
private static final Pattern THE_BODY =
Pattern.compile("\\$\\{body(\\??\\.[A-Za-z_]\\w*|\\[[^\\]]+])*}");
+ /** $.sku or $['sku']: a jsonpath that reads a field of the body, which is
then an object. */
+ private static final Pattern JSONPATH_FIELD =
Pattern.compile("^\\$\\s*(\\.\\s*[A-Za-z_]|\\[\\s*['\"])");
+
/** The data formats whose unmarshal makes a Map or a List of the text. */
private static final Set<String> PARSES = Set.of("json", "jacksonXml",
"yaml", "csv");
@@ -136,6 +139,12 @@ final class TextBodyFlow {
/** The HTTP methods that send the body. */
private static final Set<String> SENDS_A_BODY = Set.of("POST", "PUT",
"PATCH");
+ /** The verbs of the REST DSL, whose routes answer the call with the body
they end with. */
+ private static final Set<String> REST_VERBS = Set.of("get", "post", "put",
"patch", "delete", "head");
+
+ /** The REST binding modes that write a Map or a List for the route. */
+ private static final Set<String> BINDS = Set.of("auto", "json", "xml",
"json_xml");
+
private TextBodyFlow() {
}
@@ -169,10 +178,28 @@ final class TextBodyFlow {
}
}
- static void check(JsonNode target, NodePath path, List<Error> errors) {
+ /**
+ * What a walk knows besides the body: the route, the routes of the file,
where reports go (null for a route a step
+ * calls, which reports from its own walk), whether an http producer sends
the body with POST, and the verbs of the
+ * OpenAPI operations a rest-openapi call can be judged by (empty when a
REST binding may write the body).
+ */
+ private record Walk(String routeId, Map<String, JsonNode> byFrom,
List<Error> errors, Set<String> seen, boolean post,
+ Map<String, Map<String, String>> operations) {
+
+ /** The walk of a route a step calls: for its outcome alone. */
+ Walk called() {
+ return new Walk(null, byFrom, null, seen, false, operations);
+ }
+ }
+
+ static void check(JsonNode target, NodePath path, List<Error> errors,
ProjectFacts facts) {
if (target == null || !target.isArray()) {
return;
}
+ // a rest-openapi call and a REST route can only be judged when no
REST binding can write the data
+ boolean unbound = restUnbound(target, facts);
+ Map<String, Map<String, String>> operations = unbound ?
facts.operations() : Map.of();
+ Set<String> restReplies = unbound ? restReplies(target, facts) :
Set.of();
Map<String, JsonNode> byFrom = new HashMap<>();
for (JsonNode entry : target) {
JsonNode from = from(entry);
@@ -201,15 +228,20 @@ final class TextBodyFlow {
at = at.append("from").append("steps");
}
String id = entry.has("route") && entry.get("route").has("id") ?
entry.get("route").get("id").asText() : null;
- String consumer = scheme(endpointOf(from));
+ String uri = endpointOf(from);
+ String consumer = scheme(uri);
boolean post = NO_HTTP_HEADERS.contains(consumer) &&
!setsHttpHeaders(steps);
- Body end = walk(steps, consumerText(from), at, id, byFrom, errors,
new HashSet<>(), post);
- // the body at the end of a platform-http route is the reply,
written as a stream
- if ("platform-http".equals(consumer) && end instanceof Parsed
parsed && steps != null && steps.isArray()
- && !steps.isEmpty() && steps.get(steps.size() -
1).fieldNames().hasNext()) {
+ Body end = walk(steps, consumerText(from), at, new Walk(id,
byFrom, errors, new HashSet<>(), post, operations));
+ // the body at the end of a platform-http route, or of the route
of a REST call, is the reply
+ boolean rest = uri != null && restReplies.contains(normalize(uri));
+ if (("platform-http".equals(consumer) || rest) && end instanceof
Parsed parsed && steps != null
+ && steps.isArray() && !steps.isEmpty() &&
steps.get(steps.size() - 1).fieldNames().hasNext()) {
int last = steps.size() - 1;
errors.add(error(at.append(last).append(steps.get(last).fieldNames().next()),
id,
- new Need("the body at the end of the route is the
reply to the HTTP request", "as the last step"),
+ new Need(
+ "the body at the end of the route is the reply
to the "
+ + (rest ? "REST call" : "HTTP request"),
+ "as the last step", rest),
parsed));
}
}
@@ -253,11 +285,9 @@ final class TextBodyFlow {
* Walks the steps in order with what is known about the body, reports the
reads that need parsed data where it is
* text and the steps that need text where it is parsed data, and answers
what is known at the end. A report is only
* made when errors is not null: the routes a step calls are walked for
their outcome alone, and report from their
- * own walk. post says an http producer here sends the body with POST:
nothing in the route can pick another method.
+ * own walk.
*/
- private static Body walk(
- JsonNode steps, Body body, NodePath at, String routeId,
Map<String, JsonNode> byFrom,
- List<Error> errors, Set<String> seen, boolean post) {
+ private static Body walk(JsonNode steps, Body body, NodePath at, Walk w) {
if (steps == null || !steps.isArray()) {
return body;
}
@@ -270,25 +300,25 @@ final class TextBodyFlow {
String name = step.fieldNames().next();
JsonNode value = step.get(name);
NodePath here = at.append(j).append(name);
- if (body != null && errors != null) {
+ if (body != null && w.errors() != null) {
if (SAME_BODY_BLOCKS.contains(name)) {
- walkBlock(value, body, here, routeId, byFrom, errors,
seen, post);
+ walkBlock(value, body, here, w);
} else if (body instanceof Text text) {
String read = readIn(value, text.readable());
if (read != null) {
- errors.add(error(here, routeId, read, text));
+ w.errors().add(error(here, w.routeId(), read, text));
}
} else if (body instanceof Parsed parsed) {
- Need need = needsText(name, value, post);
+ Need need = needsText(name, value, w);
Parsed element = "split".equals(name) ?
splitElement(value, parsed) : null;
if (need != null) {
- errors.add(error(here, routeId, need, parsed));
+ w.errors().add(error(here, w.routeId(), need, parsed));
} else if (element != null) {
- walk(value.get("steps"), element,
here.append("steps"), routeId, byFrom, errors, seen, post);
+ walk(value.get("steps"), element,
here.append("steps"), w);
}
}
}
- body = after(name, value, step, body, byFrom, seen);
+ body = after(name, value, body, w);
}
return body;
}
@@ -296,15 +326,13 @@ final class TextBodyFlow {
/**
* A choice, filter or doTry: its predicates read the body at the step,
and so do its steps until one changes it.
*/
- private static void walkBlock(
- JsonNode node, Body body, NodePath at, String routeId, Map<String,
JsonNode> byFrom,
- List<Error> errors, Set<String> seen, boolean post) {
+ private static void walkBlock(JsonNode node, Body body, NodePath at, Walk
w) {
if (node == null) {
return;
}
if (node.isArray()) {
for (int i = 0; i < node.size(); i++) {
- walkBlock(node.get(i), body, at.append(i), routeId, byFrom,
errors, seen, post);
+ walkBlock(node.get(i), body, at.append(i), w);
}
return;
}
@@ -315,21 +343,20 @@ final class TextBodyFlow {
String key = it.next();
JsonNode child = node.get(key);
if ("steps".equals(key)) {
- walk(child, body, at.append(key), routeId, byFrom, errors,
seen, post);
+ walk(child, body, at.append(key), w);
} else if (SAME_BODY_BLOCKS.contains(key) || child.isArray()) {
- walkBlock(child, body, at.append(key), routeId, byFrom,
errors, seen, post);
+ walkBlock(child, body, at.append(key), w);
} else if (body instanceof Text text) {
String read = readEntry(key, child, text.readable());
if (read != null) {
- errors.add(error(at.append(key), routeId, read, text));
+ w.errors().add(error(at.append(key), w.routeId(), read,
text));
}
}
}
}
/** What is known about the body after a step. */
- private static Body after(
- String name, JsonNode value, JsonNode step, Body body, Map<String,
JsonNode> byFrom, Set<String> seen) {
+ private static Body after(String name, JsonNode value, Body body, Walk w) {
switch (name) {
case "setBody", "transform" -> {
Text text = setBodyText(value);
@@ -365,16 +392,15 @@ final class TextBodyFlow {
if (scheme != null && KEEPS_THE_BODY.contains(scheme)) {
return body;
}
- JsonNode called = uri != null ? byFrom.get(normalize(uri)) :
null;
- if (called == null || !"to".equals(name) ||
!seen.add(normalize(uri))) {
+ JsonNode called = uri != null ? w.byFrom().get(normalize(uri))
: null;
+ if (called == null || !"to".equals(name) ||
!w.seen().add(normalize(uri))) {
return null;
}
JsonNode from = from(called);
JsonNode steps = from.has("steps") ? from.get("steps")
: called.has("route") ?
called.get("route").get("steps") : null;
- Body end = walk(steps, body, new
NodePath(PathType.JSON_POINTER), null, byFrom,
- null, seen, false);
- seen.remove(normalize(uri));
+ Body end = walk(steps, body, new
NodePath(PathType.JSON_POINTER), w.called());
+ w.seen().remove(normalize(uri));
if (end instanceof Text t) {
return new Text(
"to: " + normalize(uri) + " returns it as text ("
+ t.origin() + ")", t.format(),
@@ -473,16 +499,19 @@ final class TextBodyFlow {
}
/**
- * The elements a split makes of parsed data: data, when it splits the
body or a field of it with simple, and its
- * steps read fields of each element before they change it, as
${body[sku]} does. Otherwise null: a List may hold
- * text as well.
+ * The elements a split makes of parsed data: data, when it splits the
body or a field of it (with simple, or with
+ * jsonpath, which reads parsed data as well), and its steps read fields
of each element before they change it, as
+ * ${body[sku]} or a jsonpath $.sku does. Otherwise null: a List may hold
text as well.
*/
private static Parsed splitElement(JsonNode value, Parsed parsed) {
JsonNode node = value != null && value.has("expression") ?
value.get("expression") : value;
- String expression = node != null && node.isObject() ?
expressionText(node.get("simple")) : null;
+ String language = node != null && node.isObject() &&
node.fieldNames().hasNext() ? node.fieldNames().next() : null;
+ String expression = language != null ?
expressionText(node.get(language)) : null;
JsonNode steps = value != null ? value.get("steps") : null;
- if (expression == null ||
!THE_BODY.matcher(expression.trim()).matches() || steps == null
- || !steps.isArray()) {
+ boolean ofTheBody = expression != null
+ && (("simple".equals(language) &&
THE_BODY.matcher(expression.trim()).matches())
+ || ("jsonpath".equals(language) &&
expression.trim().startsWith("$")));
+ if (!ofTheBody || steps == null || !steps.isArray()) {
return null;
}
for (JsonNode step : steps) {
@@ -490,7 +519,7 @@ final class TextBodyFlow {
return null;
}
String name = step.fieldNames().next();
- if (readIn(step.get(name), TEXT_PROPERTIES) != null) {
+ if (readIn(step.get(name), TEXT_PROPERTIES) != null ||
readsAFieldWithJsonPath(step.get(name))) {
return new Parsed(
parsed.origin() + ", and split " + expression.trim() +
" gives its elements as data",
parsed.format());
@@ -502,12 +531,60 @@ final class TextBodyFlow {
return null;
}
- /** What a step that needs the body as text does with it, and what to
write; or null. */
- private record Need(String what, String fix) {
+ /** Whether a node, not its nested steps, has a jsonpath expression that
reads a field of the body. */
+ private static boolean readsAFieldWithJsonPath(JsonNode node) {
+ if (node == null) {
+ return false;
+ }
+ if (node.isArray()) {
+ for (JsonNode child : node) {
+ if (readsAFieldWithJsonPath(child)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ if (!node.isObject()) {
+ return false;
+ }
+ for (var it = node.fieldNames(); it.hasNext();) {
+ String key = it.next();
+ if ("steps".equals(key)) {
+ continue;
+ }
+ if ("jsonpath".equals(key)) {
+ String path = expressionText(node.get(key));
+ if (path != null &&
JSONPATH_FIELD.matcher(path.trim()).find()) {
+ return true;
+ }
+ } else if (readsAFieldWithJsonPath(node.get(key))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * What a step that needs the body as text does with it, and what to
write; rest when a REST binding could write it.
+ */
+ private record Need(String what, String fix, boolean rest) {
+
+ Need(String what, String fix) {
+ this(what, fix, false);
+ }
}
- /** A to: that needs the body as text: one that writes a file, or an http
producer that sends it with POST. */
- private static Need needsText(String name, JsonNode value, boolean post) {
+ /**
+ * An OpenAPI operation a rest-openapi call names: the specification, as
{@link ProjectFacts} keys it, and the id.
+ */
+ private record Operation(String spec, String id) {
+ }
+
+ /**
+ * A to: that needs the body as text: one that writes a file, an http
producer that sends it with POST, or a
+ * rest-openapi call to an operation whose verb sends it (the producer
calls with the verb of the specification).
+ */
+ private static Need needsText(String name, JsonNode value, Walk w) {
if (!"to".equals(name) && !"toD".equals(name)) {
return null;
}
@@ -519,14 +596,111 @@ final class TextBodyFlow {
if (WRITES_A_FILE.contains(scheme)) {
return new Need(name + ": " + scheme + " writes the body to a
file", "before this step");
}
- if (post && ("http".equals(scheme) || "https".equals(scheme)) &&
sendsTheBody(uri, value)) {
+ if (w.post() && ("http".equals(scheme) || "https".equals(scheme)) &&
sendsTheBody(uri, value)) {
return new Need(
name + ": " + scheme + " sends the body as the request (a
message with a body is sent as a POST)",
"before this step, or set httpMethod: GET when the call
sends no body");
}
+ if ("rest-openapi".equals(scheme)) {
+ Operation operation = operationOf(uri, value);
+ String verb = operation != null
+ ? w.operations().getOrDefault(operation.spec(),
Map.of()).get(operation.id())
+ : null;
+ if (verb != null &&
SENDS_A_BODY.contains(verb.toUpperCase(Locale.ROOT))) {
+ return new Need(
+ name + ": rest-openapi sends the body as the request
of the operation " + operation.id() + " ("
+ + verb.toUpperCase(Locale.ROOT) + " in " +
operation.spec() + ")",
+ "before this step", true);
+ }
+ }
return null;
}
+ /**
+ * The operation a rest-openapi call names: rest-openapi:spec#operationId,
rest-openapi:operationId with the default
+ * specification, or the specificationUri and operationId options. Null
when no operation is named.
+ */
+ private static Operation operationOf(String uri, JsonNode value) {
+ JsonNode parameters = value != null && value.isObject() ?
value.get("parameters") : null;
+ String path = uri.length() > "rest-openapi".length() ?
uri.substring("rest-openapi".length() + 1) : "";
+ if (path.startsWith("//")) {
+ path = path.substring(2);
+ }
+ int q = path.indexOf('?');
+ if (q >= 0) {
+ path = path.substring(0, q);
+ }
+ String spec = parameters != null && parameters.has("specificationUri")
+ ? parameters.get("specificationUri").asText() : null;
+ String id = parameters != null && parameters.has("operationId") ?
parameters.get("operationId").asText() : null;
+ int hash = path.indexOf('#');
+ if (hash >= 0) {
+ spec = spec != null ? spec : path.substring(0, hash);
+ id = id != null ? id : path.substring(hash + 1);
+ } else if (id == null && !path.isEmpty()) {
+ id = path;
+ }
+ return id == null || id.isBlank() ? null : new
Operation(ProjectFacts.specName(spec), id);
+ }
+
+ /**
+ * Whether a REST binding cannot write a Map for the routes of the file:
the project's properties are known to set
+ * none, and neither a restConfiguration nor a rest of the file sets one
that writes data. Not known is not unbound.
+ */
+ private static boolean restUnbound(JsonNode target, ProjectFacts facts) {
+ if (facts == null || facts.restBindingMode() == null
+ ||
BINDS.contains(facts.restBindingMode().toLowerCase(Locale.ROOT))) {
+ return false;
+ }
+ for (JsonNode entry : target) {
+ JsonNode configuration = entry.isObject() ?
entry.get("restConfiguration") : null;
+ if (configuration != null && binds(configuration)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** Whether a node of the REST DSL sets a binding mode that writes data. */
+ private static boolean binds(JsonNode node) {
+ JsonNode mode = node != null && node.isObject() ?
node.get("bindingMode") : null;
+ return mode != null &&
BINDS.contains(mode.asText().toLowerCase(Locale.ROOT));
+ }
+
+ /**
+ * The endpoints whose route answers a REST call with the body it ends
with: the routes of the REST verbs, and the
+ * {@code direct:<operationId>} routes of the operations of a
specification the REST DSL binds to (contract first).
+ * Only for a rest, verb or openApi that sets no binding mode that writes
data.
+ */
+ private static Set<String> restReplies(JsonNode target, ProjectFacts
facts) {
+ Set<String> answer = new HashSet<>();
+ for (JsonNode entry : target) {
+ JsonNode rest = entry.isObject() ? entry.get("rest") : null;
+ if (rest == null || !rest.isObject() || binds(rest)) {
+ continue;
+ }
+ for (var it = rest.fieldNames(); it.hasNext();) {
+ String key = it.next();
+ JsonNode value = rest.get(key);
+ if ("openApi".equals(key) && value.isObject() &&
value.has("specification") && !binds(value)) {
+ Map<String, String> operations
+ =
facts.operations().get(ProjectFacts.specName(value.get("specification").asText()));
+ if (operations != null) {
+ operations.keySet().forEach(id -> answer.add("direct:"
+ id));
+ }
+ } else if (REST_VERBS.contains(key)) {
+ for (JsonNode operation : value.isArray() ? value :
List.of(value)) {
+ String uri = binds(operation) ? null :
endpointOf(operation.get("to"));
+ if (uri != null) {
+ answer.add(normalize(uri));
+ }
+ }
+ }
+ }
+ }
+ return answer;
+ }
+
/**
* Whether an http producer sends the body: it picks POST for a body,
unless the httpMethod option says otherwise,
* or a query string (in the uri, or a parameter that is not an option of
the producer) makes it a GET.
@@ -708,6 +882,15 @@ final class TextBodyFlow {
.build();
}
+ /** The REST binding writes json and xml: offer it where it applies. */
+ private static String restBinding(Need need, Parsed parsed) {
+ if (!need.rest()) {
+ return "";
+ }
+ return "json".equals(parsed.format()) ? ", or let the REST binding
write it (bindingMode: json)"
+ : "jacksonXml".equals(parsed.format()) ? ", or let the REST
binding write it (bindingMode: xml)" : "";
+ }
+
private static Error error(NodePath at, String routeId, Need need, Parsed
parsed) {
return Error.builder()
.keyword("type")
@@ -716,7 +899,7 @@ final class TextBodyFlow {
.format(new MessageFormat("{0}"))
.arguments((routeId != null ? "route " + routeId + ": " : "")
+ need.what()
+ ", but the body here is parsed data (a Map or a
List), not text - " + parsed.origin()
- + ": add marshal: " + parsed.format() + " " +
need.fix())
+ + ": add marshal: " + parsed.format() + " " +
need.fix() + restBinding(need, parsed))
.build();
}
}
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 6e94906e8dc3..6b787a151989 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
@@ -179,6 +179,15 @@ public class YamlValidator {
* GET operation of an OpenAPI specification the
file binds to (CAMEL-24844)
*/
public List<Error> validate(String content, Set<String> bodylessEndpoints)
throws Exception {
+ return validate(content, new ProjectFacts(bodylessEndpoints, Map.of(),
null));
+ }
+
+ /**
+ * @param facts what the caller read from the files beside the route: the
endpoints of the OpenAPI operations that
+ * carry no body, the verbs of the operations the routes use,
and the REST binding mode of the project
+ * (CAMEL-25762)
+ */
+ public List<Error> validate(String content, ProjectFacts facts) throws
Exception {
if (schema == null) {
init();
}
@@ -198,7 +207,7 @@ public class YamlValidator {
}
try {
var target = mapper.readTree(content);
- List<Error> errors = validate(target, bodylessEndpoints);
+ List<Error> errors = validate(target, facts != null ? facts :
ProjectFacts.NONE);
// CAMEL-25560: the tree keeps the last of two equal keys, so a
key given twice is only seen in the YAML nodes
List<String> duplicates =
DuplicateKeys.find(YamlPointerLines.root(content));
if (duplicates.isEmpty()) {
@@ -728,7 +737,7 @@ public class YamlValidator {
return kind != null && kind.isTextual() &&
"Kamelet".equals(kind.asText());
}
- private List<Error> validate(JsonNode target, Set<String>
bodylessEndpoints) {
+ private List<Error> validate(JsonNode target, ProjectFacts facts) {
Schema against = isKamelet(target) ? kameletSchema : schema;
// A branch only matches after scalar conversion if it has no other
errors, including missing required fields.
var errors = filterOneOfNoise(new
ArrayList<>(against.validate(target)));
@@ -792,9 +801,9 @@ public class YamlValidator {
checkSimpleSyntaxInScripts(camelOnly, new
NodePath(PathType.JSON_POINTER), errors);
checkDynamicUri(camelOnly, new NodePath(PathType.JSON_POINTER),
errors);
// where the body comes from, across the routes of the file
(CAMEL-24844)
- BodyTypeFlow.check(camelOnly, new NodePath(PathType.JSON_POINTER),
errors, bodylessEndpoints);
+ BodyTypeFlow.check(camelOnly, new NodePath(PathType.JSON_POINTER),
errors, facts.bodylessEndpoints());
// a field read on a body that is still text (CAMEL-24844)
- TextBodyFlow.check(camelOnly, new NodePath(PathType.JSON_POINTER),
errors);
+ TextBodyFlow.check(camelOnly, new NodePath(PathType.JSON_POINTER),
errors, facts);
}
if (canonical) {
checkOneOfCardinality(camelOnly, new
NodePath(PathType.JSON_POINTER), errors);
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/TextBodyFlowTest.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/TextBodyFlowTest.java
index 2498535e2009..09940ee1f4a3 100644
---
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/TextBodyFlowTest.java
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/TextBodyFlowTest.java
@@ -17,6 +17,8 @@
package org.apache.camel.dsl.yaml.validator;
import java.util.List;
+import java.util.Map;
+import java.util.Set;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
@@ -571,6 +573,175 @@ public class TextBodyFlowTest {
return node;
}
+ /** The facts camel-jbang reads beside a route: stock-api.json, and
properties that set no REST binding. */
+ private static final ProjectFacts STOCK_API = new ProjectFacts(
+ Set.of(), Map.of("stock-api.json", Map.of("reserveStock", "post",
"getStock", "get")), "off");
+
+ @Test
+ public void testRestOpenApiCallOfAPostOperation() {
+ // what the benchmark wrote: the order is unmarshalled to read its
lines, and sent as it is
+ String yaml = """
+ - route:
+ id: reserve
+ from:
+ uri: file:orders
+ steps:
+ - unmarshal:
+ json: {}
+ - to:
+ uri: rest-openapi:stock-api.json#reserveStock
+ parameters:
+ host: http://localhost:8080
+ """;
+ assertThat(lines(yaml, STOCK_API)).singleElement().satisfies(m -> {
+ assertThat(m).startsWith("Line 9: ");
+ assertThat(m).contains("route reserve: to: rest-openapi sends the
body as the request of the operation"
+ + " reserveStock (POST in stock-api.json)");
+ assertThat(m).contains("add marshal: json before this step, or let
the REST binding write it (bindingMode: json)");
+ });
+ // the same operation named by options, or with the specification as a
file: resource
+ String options = """
+ - route:
+ from:
+ uri: file:orders
+ steps:
+ - unmarshal:
+ json: {}
+ - to:
+ uri: rest-openapi
+ parameters:
+ specificationUri: stock-api.json
+ operationId: reserveStock
+ """;
+ assertThat(lines(options, STOCK_API)).singleElement().asString()
+ .contains("the operation reserveStock (POST in
stock-api.json)");
+ assertThat(lines(yaml.replace("rest-openapi:stock-api.json",
"rest-openapi:file:stock-api.json"), STOCK_API))
+ .hasSize(1);
+ }
+
+ @Test
+ public void testRestOpenApiCallOfTheLinesOfAJsonPathSplit() {
+ // what the benchmark wrote: the order lines are split with jsonpath,
and each line, a Map, is sent as it is
+ String yaml = """
+ - route:
+ id: reserve-order-lines
+ from:
+ uri: file:orders
+ steps:
+ - unmarshal:
+ json: {}
+ - split:
+ expression:
+ jsonpath:
+ expression: "$.lines"
+ steps:
+ - setHeader:
+ name: sku
+ expression:
+ jsonpath:
+ expression: "$.sku"
+ - to:
+ uri: rest-openapi
+ parameters:
+ specificationUri: stock-api.json
+ operationId: reserveStock
+ """;
+ assertThat(lines(yaml, STOCK_API)).singleElement().satisfies(m -> {
+ assertThat(m).contains("rest-openapi sends the body as the request
of the operation reserveStock");
+ assertThat(m).contains("split $.lines gives its elements as data");
+ });
+ // a jsonpath that reads no field of the element: the element may be
text
+ assertThat(lines(yaml.replace("expression: \"$.sku\"", "expression:
\"$\""), STOCK_API)).isEmpty();
+ }
+
+ @Test
+ public void testRestOpenApiCallsThatAreFine() {
+ String post = """
+ - route:
+ from:
+ uri: file:orders
+ steps:
+ - unmarshal:
+ json: {}
+ - to: rest-openapi:stock-api.json#reserveStock
+ """;
+ // a GET sends no body; an operation or a specification the facts do
not know says nothing
+ assertThat(lines(post.replace("reserveStock", "getStock"),
STOCK_API)).isEmpty();
+ assertThat(lines(post.replace("reserveStock", "cancelStock"),
STOCK_API)).isEmpty();
+ assertThat(lines(post.replace("stock-api.json", "orders-api.json"),
STOCK_API)).isEmpty();
+ // a REST binding writes the Map: in the properties, in the file, or
not known
+ assertThat(lines(post, new ProjectFacts(Set.of(),
STOCK_API.operations(), "json"))).isEmpty();
+ assertThat(lines("- restConfiguration:\n bindingMode: json\n" +
post, STOCK_API)).isEmpty();
+ assertThat(lines(post, new ProjectFacts(Set.of(),
STOCK_API.operations(), null))).isEmpty();
+ assertThat(lines(post)).isEmpty();
+ }
+
+ @Test
+ public void testRestRouteReply() {
+ String yaml = """
+ - rest:
+ get:
+ - path: /stock/{sku}
+ to: direct:stock
+ - route:
+ id: stock
+ from:
+ uri: direct:stock
+ steps:
+ - setBody:
+ constant: resource:file:stock.json
+ - unmarshal:
+ json: {}
+ - setBody:
+ groovy: body.find { it.sku == headers.sku }
+ """;
+ assertThat(lines(yaml, STOCK_API)).singleElement().satisfies(m -> {
+ assertThat(m).startsWith("Line 15: ");
+ assertThat(m).contains("route stock: the body at the end of the
route is the reply to the REST call");
+ assertThat(m).contains("add marshal: json as the last step, or let
the REST binding write it (bindingMode: json)");
+ });
+ // a binding on the verb, the rest or the configuration writes it; and
nothing is said when it is not known
+ assertThat(lines(yaml.replace(" to: direct:stock", " to:
direct:stock\n bindingMode: json"),
+ STOCK_API)).isEmpty();
+ assertThat(lines(yaml.replace("- rest:\n", "- rest:\n bindingMode:
auto\n"), STOCK_API)).isEmpty();
+ assertThat(lines("- restConfiguration:\n bindingMode: json\n" +
yaml, STOCK_API)).isEmpty();
+ assertThat(lines(yaml, new ProjectFacts(Set.of(), Map.of(),
"json"))).isEmpty();
+ assertThat(lines(yaml)).isEmpty();
+ }
+
+ @Test
+ public void testContractFirstOperationReply() {
+ String yaml = """
+ - rest:
+ openApi:
+ specification: stock-api.json
+ - route:
+ id: getStock
+ from:
+ uri: direct:getStock
+ steps:
+ - setBody:
+ constant: resource:file:stock.json
+ - unmarshal:
+ json: {}
+ """;
+ assertThat(lines(yaml, STOCK_API)).singleElement().satisfies(m -> {
+ assertThat(m).contains("route getStock: the body at the end of the
route is the reply to the REST call");
+ assertThat(m).contains("add marshal: json as the last step");
+ });
+ // the operations of the specification are what makes direct:getStock
the route of a REST call
+ assertThat(lines(yaml, new ProjectFacts(Set.of(), Map.of(),
"off"))).isEmpty();
+ assertThat(lines(yaml.replace("direct:getStock", "direct:audit"),
STOCK_API)).isEmpty();
+ }
+
+ private static List<String> lines(String yaml, ProjectFacts facts) {
+ try {
+ return YamlValidator.describeAll(yaml, validator.validate(yaml,
facts));
+ } catch (Exception e) {
+ throw new AssertionError("Failed to validate:\n" + yaml, e);
+ }
+ }
+
private static List<String> lines(String yaml) {
try {
return YamlValidator.describeAll(yaml, validator.validate(yaml));