This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch feature/CAMEL-25762-validator-project-facts in repository https://gitbox.apache.org/repos/asf/camel.git
commit ca3901cd73b3628ce56afdd07b85aaf4938ec7b4 Author: Claus Ibsen <[email protected]> AuthorDate: Sun Oct 18 14:59:21 2026 +0200 CAMEL-25762: camel-jbang - the validator learns the OpenAPI operations and the REST binding mode beside a route The parsed-data check of CAMEL-24844 missed the cases whose facts are not in the route file. camel-jbang now passes them, read from the files beside the route, as it already passed the operations without a body: - the operations of the OpenAPI specifications the file uses (contract first, and rest-openapi calls): the verb of each operationId - the REST binding mode the project's properties set (off when none) With them the validator 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 with jsonpath, now 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. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../dsl/jbang/core/commands/ai/OpenApiVerbs.java | 68 ++++- .../jbang/core/commands/ai/SourceValidator.java | 23 +- .../dsl/jbang/core/commands/ai/ValidatorFacts.java | 103 ++++++++ .../jbang/core/commands/ai/ValidatorFactsTest.java | 169 +++++++++++++ .../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, 826 insertions(+), 56 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..ffe2f6aef08e 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,9 +48,71 @@ 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 - @@ -73,7 +139,7 @@ public final class OpenApiVerbs { continue; } for (Map.Entry<?, ?> operation : operations.entrySet()) { - String verb = String.valueOf(operation.getKey()).toLowerCase(java.util.Locale.ROOT); + 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")); 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..7fb281085280 --- /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,169 @@ +/* + * 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 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));
