This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch feature/CAMEL-24844-parsed-data-needs-text in repository https://gitbox.apache.org/repos/asf/camel.git
commit f27df5f4e160145358e20f03d5509d9685216d11 Author: Claus Ibsen <[email protected]> AuthorDate: Sun Oct 18 11:25:05 2026 +0200 CAMEL-24844: the validator reports parsed data that is sent or written where text is needed After unmarshal (json, jacksonXml, yaml, csv) the body is a Map or a List. TextBodyFlow now follows that state, through a Groovy collection call that keeps it data (body.find { it.sku == ... }) and through a split whose steps read fields of each element, and reports it where the runtime fails with "No type converter available to convert from type: java.util.LinkedHashMap to the required type: java.io.InputStream": - a to: file (ftp, ftps, sftp) that writes it - an http producer that sends it with POST, in a route whose consumer brings no HTTP headers - the end of a platform-http route, where the body is the reply No report on 1101 human-written route files. On the routes the local model wrote in the r17 benchmark series, it catches the order lines posted to the stock service (2 of the 3 runs that failed on it). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../camel/dsl/yaml/validator/TextBodyFlow.java | 321 ++++++++++++++++++--- .../camel/dsl/yaml/validator/TextBodyFlowTest.java | 285 ++++++++++++++++++ 2 files changed, 567 insertions(+), 39 deletions(-) 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 1a90314cfb73..9554ee9ab067 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 @@ -16,6 +16,7 @@ */ package org.apache.camel.dsl.yaml.validator; +import java.text.MessageFormat; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -28,6 +29,7 @@ import java.util.regex.Pattern; import com.fasterxml.jackson.databind.JsonNode; import com.networknt.schema.Error; import com.networknt.schema.path.NodePath; +import com.networknt.schema.path.PathType; import org.apache.camel.util.MimeTypeHelper; import static org.apache.camel.dsl.yaml.validator.RouteGraph.endpointOf; @@ -43,6 +45,11 @@ import static org.apache.camel.dsl.yaml.validator.RouteGraph.scheme; * such property: sku for class: java.lang.Byte". The walk follows the steps of each route in order, and into the * {@code direct:} and {@code seda:} routes it calls, and reports only where it is certain the body is text: anything it * does not know makes it say nothing (CAMEL-24844). + * <p/> + * The other way round, parsed data where a step needs text: after {@code unmarshal: json} the body is a Map or a List, + * and a {@code to: file}, an http producer that sends it with POST, or the reply of a {@code platform-http} route fails + * at runtime, because no type converter turns a Map into the stream they write: "No type converter available to convert + * from type: java.util.LinkedHashMap to the required type: java.io.InputStream". */ final class TextBodyFlow { @@ -83,11 +90,69 @@ final class TextBodyFlow { /** Endpoints that leave the body as it is. */ private static final Set<String> KEEPS_THE_BODY = Set.of("log", "mock"); + /** Steps that leave the body as it is. */ + private static final Set<String> KEEPING_STEPS = Set.of("setHeader", "setHeaders", "setProperty", "setVariable", + "removeHeader", "removeHeaders", "removeProperty", "removeProperties", "log", "wireTap", "delay", "throttle", + "stop"); + + /** ${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*|\\[[^\\]]+])*}"); + + /** 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"); + + /** The unmarshal options that make something else of the text: an object of a class, or an iterator. */ + private static final Set<String> NOT_A_MAP = Set.of("unmarshalType", "unmarshalTypeName", "lazyLoad"); + + /** The Groovy collection methods whose result is data again: a List or a Map. */ + private static final Set<String> GIVES_DATA = Set.of("findAll", "collect", "collectEntries", "grep", "sort", + "groupBy", "unique"); + + /** The Groovy collection methods that pick an element, which is data when the closure reads its fields. */ + private static final Set<String> PICKS = Set.of("find", "max", "min"); + + /** body.findAll {, the start of a collection method on the body. */ + private static final Pattern COLLECTION_CALL = Pattern.compile("body\\s*\\??\\.\\s*([A-Za-z_]\\w*)\\s*\\{"); + + /** The parameter of a closure: x -> ... */ + private static final Pattern CLOSURE_PARAMETER = Pattern.compile("^\\s*([A-Za-z_]\\w*)\\s*->"); + + /** The producers that write the body to a file. */ + private static final Set<String> WRITES_A_FILE = Set.of("file", "ftp", "ftps", "sftp"); + + /** + * The consumers that bring no HTTP control headers, so an http producer after them picks POST for a body: after an + * HTTP consumer, the CamelHttpMethod header of the request picks the method instead. + */ + private static final Set<String> NO_HTTP_HEADERS = Set.of("file", "ftp", "ftps", "sftp", "timer", "scheduler", + "cron", "quartz"); + + /** The options of the http producer: any other parameter goes into the query string, which makes the call a GET. */ + private static final Set<String> HTTP_OPTIONS = Set.of("httpMethod", "throwExceptionOnFailure", "bridgeEndpoint", + "okStatusCodeRange", "followRedirects", "copyHeaders", "skipRequestHeaders", "skipResponseHeaders", + "disableStreamCache", "authMethod", "authUsername", "authPassword", "authBearerToken", + "sslContextParameters", "userAgent", "logHttpActivity", "lazyStartProducer", "useSystemProperties"); + + /** The HTTP methods that send the body. */ + private static final Set<String> SENDS_A_BODY = Set.of("POST", "PUT", "PATCH"); + private TextBodyFlow() { } - /** What is known about the body at a step: text, and where it came from, or nothing (null). */ - record Text(String origin, String format, boolean file) { + /** What is known about the body at a step: text or parsed data, and where it came from; or nothing (null). */ + sealed interface Body permits Text, Parsed { + + String origin(); + + String format(); + } + + /** Parsed data: the Map or the List an unmarshal makes of the text. */ + record Parsed(String origin, String format) implements Body { + } + + /** Text: the payload as it was read or written. */ + record Text(String origin, String format, boolean file) implements Body { Text(String origin, String format) { this(origin, format, false); @@ -136,10 +201,28 @@ 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; - walk(steps, consumerText(from), at, id, byFrom, errors, new HashSet<>()); + String consumer = scheme(endpointOf(from)); + 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()) { + 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"), + parsed)); + } } } + /** + * Whether the steps set or read an HTTP control header (CamelHttpMethod, CamelHttpQuery...), which can make an http + * producer call with GET and leave the body out. + */ + private static boolean setsHttpHeaders(JsonNode steps) { + return steps != null && steps.toString().toLowerCase(Locale.ROOT).contains("camelhttp"); + } + private static JsonNode from(JsonNode entry) { if (entry == null || !entry.isObject()) { return null; @@ -167,51 +250,60 @@ 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 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. + * 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. */ - private static Text walk( - JsonNode steps, Text text, NodePath at, String routeId, Map<String, JsonNode> byFrom, - List<Error> errors, Set<String> seen) { + private static Body walk( + JsonNode steps, Body body, NodePath at, String routeId, Map<String, JsonNode> byFrom, + List<Error> errors, Set<String> seen, boolean post) { if (steps == null || !steps.isArray()) { - return text; + return body; } for (int j = 0; j < steps.size(); j++) { JsonNode step = steps.get(j); if (!step.isObject() || !step.fieldNames().hasNext()) { - text = null; + body = null; continue; } String name = step.fieldNames().next(); JsonNode value = step.get(name); NodePath here = at.append(j).append(name); - if (text != null && errors != null) { + if (body != null && errors != null) { if (SAME_BODY_BLOCKS.contains(name)) { - walkBlock(value, text, here, routeId, byFrom, errors, seen); - } else { + walkBlock(value, body, here, routeId, byFrom, errors, seen, post); + } else if (body instanceof Text text) { String read = readIn(value, text.readable()); if (read != null) { errors.add(error(here, routeId, read, text)); } + } else if (body instanceof Parsed parsed) { + Need need = needsText(name, value, post); + Parsed element = "split".equals(name) ? splitElement(value, parsed) : null; + if (need != null) { + errors.add(error(here, routeId, need, parsed)); + } else if (element != null) { + walk(value.get("steps"), element, here.append("steps"), routeId, byFrom, errors, seen, post); + } } } - text = after(name, value, step, text, byFrom, seen); + body = after(name, value, step, body, byFrom, seen); } - return text; + return body; } /** * 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, Text text, NodePath at, String routeId, Map<String, JsonNode> byFrom, - List<Error> errors, Set<String> seen) { + JsonNode node, Body body, NodePath at, String routeId, Map<String, JsonNode> byFrom, + List<Error> errors, Set<String> seen, boolean post) { if (node == null) { return; } if (node.isArray()) { for (int i = 0; i < node.size(); i++) { - walkBlock(node.get(i), text, at.append(i), routeId, byFrom, errors, seen); + walkBlock(node.get(i), body, at.append(i), routeId, byFrom, errors, seen, post); } return; } @@ -222,10 +314,10 @@ final class TextBodyFlow { String key = it.next(); JsonNode child = node.get(key); if ("steps".equals(key)) { - walk(child, text, at.append(key), routeId, byFrom, errors, seen); + walk(child, body, at.append(key), routeId, byFrom, errors, seen, post); } else if (SAME_BODY_BLOCKS.contains(key) || child.isArray()) { - walkBlock(child, text, at.append(key), routeId, byFrom, errors, seen); - } else { + walkBlock(child, body, at.append(key), routeId, byFrom, errors, seen, post); + } 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)); @@ -235,11 +327,21 @@ final class TextBodyFlow { } /** What is known about the body after a step. */ - private static Text after( - String name, JsonNode value, JsonNode step, Text text, Map<String, JsonNode> byFrom, Set<String> seen) { + private static Body after( + String name, JsonNode value, JsonNode step, Body body, Map<String, JsonNode> byFrom, Set<String> seen) { switch (name) { case "setBody", "transform" -> { - return setBodyText(value); + Text text = setBodyText(value); + if (text != null || !(body instanceof Parsed parsed)) { + return text; + } + String call = keepsData(value); + return call != null + ? new Parsed(parsed.origin() + ", and groovy " + call + " keeps it data", parsed.format()) + : null; + } + case "unmarshal" -> { + return unmarshalled(value); } case "marshal" -> { String format = value != null && value.isObject() && value.fieldNames().hasNext() @@ -253,14 +355,14 @@ final class TextBodyFlow { String type = value != null && value.isObject() && value.has("type") ? value.get("type").asText() : value != null && value.isTextual() ? value.asText() : ""; return "String".equals(type) || "java.lang.String".equals(type) || "byte[]".equals(type) - ? new Text("convertBodyTo " + type + " makes it text", text != null ? text.format() : null) + ? new Text("convertBodyTo " + type + " makes it text", body != null ? body.format() : null) : null; } case "to", "toD" -> { String uri = endpointOf(value); String scheme = scheme(uri); if (scheme != null && KEEPS_THE_BODY.contains(scheme)) { - return text; + return body; } JsonNode called = uri != null ? byFrom.get(normalize(uri)) : null; if (called == null || !"to".equals(name) || !seen.add(normalize(uri))) { @@ -269,21 +371,20 @@ final class TextBodyFlow { JsonNode from = from(called); JsonNode steps = from.has("steps") ? from.get("steps") : called.has("route") ? called.get("route").get("steps") : null; - Text end = walk(steps, text, new NodePath(com.networknt.schema.path.PathType.JSON_POINTER), null, byFrom, - null, seen); + Body end = walk(steps, body, new NodePath(PathType.JSON_POINTER), null, byFrom, + null, seen, false); seen.remove(normalize(uri)); - return end == null - ? null - : new Text( - "to: " + normalize(uri) + " returns it as text (" + end.origin() + ")", end.format(), - end.file()); - } - case "setHeader", "setHeaders", "setProperty", "setVariable", "removeHeader", "removeHeaders", - "removeProperty", "removeProperties", "log", "wireTap", "delay", "throttle", "stop" -> { - return text; + if (end instanceof Text t) { + return new Text( + "to: " + normalize(uri) + " returns it as text (" + t.origin() + ")", t.format(), + t.file()); + } else if (end instanceof Parsed p) { + return new Parsed("to: " + normalize(uri) + " returns it as data (" + p.origin() + ")", p.format()); + } + return null; } default -> { - return null; + return KEEPING_STEPS.contains(name) ? body : null; } } } @@ -316,6 +417,136 @@ final class TextBodyFlow { return null; } + /** The body after an unmarshal: parsed data for the data formats that make a Map or a List of the text. */ + private static Parsed unmarshalled(JsonNode value) { + if (value == null || !value.isObject() || !value.fieldNames().hasNext()) { + return null; + } + String format = value.fieldNames().next(); + if (!PARSES.contains(format)) { + return null; + } + JsonNode options = value.get(format); + for (String option : NOT_A_MAP) { + if (options != null && options.isObject() && options.has(option)) { + return null; + } + } + return new Parsed("unmarshal: " + format + " turns the text into data", format); + } + + /** + * A Groovy setBody whose result is data again: one collection method on the body and nothing after it, such as + * body.findAll { ... }, or body.find { it.sku == ... }, which picks an element whose fields it reads. The call, or + * null. + */ + static String keepsData(JsonNode value) { + JsonNode node = value != null && value.has("expression") ? value.get("expression") : value; + String script = node != null && node.isObject() ? expressionText(node.get("groovy")) : null; + if (script == null) { + return null; + } + String s = script.trim(); + Matcher m = COLLECTION_CALL.matcher(s); + if (!m.lookingAt()) { + return null; + } + String method = m.group(1); + int depth = 1; + int close = m.end(); + for (; close < s.length() && depth > 0; close++) { + char c = s.charAt(close); + depth += c == '{' ? 1 : c == '}' ? -1 : 0; + } + if (depth != 0 || close != s.length()) { + // the closure does not end the script: something is done with its result + return null; + } + String closure = s.substring(m.end(), close - 1); + Matcher p = CLOSURE_PARAMETER.matcher(closure); + String element = p.find() ? p.group(1) : "it"; + boolean readsFields = Pattern.compile("(?<![\\w$])" + element + "\\s*(\\??\\.\\s*[A-Za-z_]|\\[)") + .matcher(closure).find(); + return GIVES_DATA.contains(method) || PICKS.contains(method) && readsFields + ? "body." + method + " { ... }" : null; + } + + /** + * 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. + */ + 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; + JsonNode steps = value != null ? value.get("steps") : null; + if (expression == null || !THE_BODY.matcher(expression.trim()).matches() || steps == null + || !steps.isArray()) { + return null; + } + for (JsonNode step : steps) { + if (!step.isObject() || !step.fieldNames().hasNext()) { + return null; + } + String name = step.fieldNames().next(); + if (readIn(step.get(name), TEXT_PROPERTIES) != null) { + return new Parsed( + parsed.origin() + ", and split " + expression.trim() + " gives its elements as data", + parsed.format()); + } + if (!KEEPING_STEPS.contains(name)) { + return null; + } + } + 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) { + } + + /** 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) { + if (!"to".equals(name) && !"toD".equals(name)) { + return null; + } + String uri = endpointOf(value); + String scheme = scheme(uri); + if (scheme == null) { + return null; + } + 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)) { + 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"); + } + return null; + } + + /** + * 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. + */ + private static boolean sendsTheBody(String uri, JsonNode value) { + if (uri.indexOf('?') >= 0) { + return false; + } + JsonNode parameters = value != null && value.isObject() ? value.get("parameters") : null; + if (parameters == null || !parameters.isObject()) { + return true; + } + for (var it = parameters.fieldNames(); it.hasNext();) { + if (!HTTP_OPTIONS.contains(it.next())) { + return false; + } + } + JsonNode method = parameters.get("httpMethod"); + return method == null || SENDS_A_BODY.contains(method.asText().toUpperCase(Locale.ROOT)); + } + /** The text of an expression, short form or with expression: inside. */ private static String expressionText(JsonNode node) { if (node == null) { @@ -467,7 +698,7 @@ final class TextBodyFlow { .keyword("type") .instanceLocation(at) .messageKey("type") - .format(new java.text.MessageFormat("{0}")) + .format(new MessageFormat("{0}")) .arguments((routeId != null ? "route " + routeId + ": " : "") + read + (text.file() ? ", but the body here is the file as it was read (a GenericFile), not parsed data - " @@ -475,4 +706,16 @@ final class TextBodyFlow { + text.origin() + ": " + fix) .build(); } + + private static Error error(NodePath at, String routeId, Need need, Parsed parsed) { + return Error.builder() + .keyword("type") + .instanceLocation(at) + .messageKey("type") + .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()) + .build(); + } } 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 f2b65a35919f..820a03f707ac 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 @@ -18,6 +18,9 @@ package org.apache.camel.dsl.yaml.validator; import java.util.List; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -265,6 +268,288 @@ public class TextBodyFlowTest { assertThat(lines(yaml)).isEmpty(); } + @Test + public void testParsedDataWrittenToAFile() { + // the order is unmarshalled to read its fields, and then written as it is: a Map is not file content + String yaml = """ + - route: + id: archive + from: + uri: file:inbox + steps: + - unmarshal: + json: {} + - setHeader: + name: orderId + simple: ${body[orderId]} + - to: + uri: file:archive + """; + assertThat(lines(yaml)).singleElement().satisfies(m -> { + assertThat(m).startsWith("Line 12: "); + assertThat(m).contains("route archive: to: file writes the body to a file"); + assertThat(m).contains("the body here is parsed data (a Map or a List), not text"); + assertThat(m).contains("unmarshal: json turns the text into data: add marshal: json before this step"); + }); + } + + @Test + public void testParsedDataSentWithPost() { + // a message with a body is sent as a POST, and the http producer cannot make a request body of a Map + String yaml = """ + - route: + from: + uri: file:orders + steps: + - unmarshal: + json: + library: Jackson + - to: + uri: http://localhost:8080/orders + parameters: + throwExceptionOnFailure: false + """; + assertThat(lines(yaml)).singleElement().satisfies(m -> { + assertThat(m).startsWith("Line 9: "); + assertThat(m).contains("to: http sends the body as the request (a message with a body is sent as a POST)"); + assertThat(m).contains("add marshal: json before this step, or set httpMethod: GET when the call sends no body"); + }); + } + + @Test + public void testSplitElementsSentWithPost() { + // what the benchmark wrote: each order line is a Map whose fields are read, and toD meant a GET lookup + String yaml = """ + - route: + id: check-stock + from: + uri: file:orders + steps: + - unmarshal: + json: {} + - split: + simple: "${body[lines]}" + steps: + - setHeader: + name: sku + simple: "${body[sku]}" + - toD: + uri: "http://localhost:8080/stock/${header.sku}" + """; + assertThat(lines(yaml)).singleElement().satisfies(m -> { + assertThat(m).contains("route check-stock: toD: http sends the body as the request"); + assertThat(m).contains("unmarshal: json turns the text into data, and split ${body[lines]} gives its" + + " elements as data"); + assertThat(m).contains("or set httpMethod: GET when the call sends no body"); + }); + } + + @Test + public void testSplitElementsNotReadAsDataSayNothing() { + // nothing in the split reads a field of the element, which may be text as well + String yaml = """ + - route: + from: + uri: file:orders + steps: + - unmarshal: + json: {} + - split: + simple: "${body[skus]}" + steps: + - setHeader: + name: sku + simple: "${body}" + - to: http://localhost:8080/reserve + """; + assertThat(lines(yaml)).isEmpty(); + } + + @Test + public void testHttpCallsThatSendNoBodyAreFine() { + // a query string or httpMethod: GET makes the call a GET; a CamelHttpMethod header can, and so can the + // request of an HTTP consumer, whose headers the producer sees + String yaml = """ + - route: + from: + uri: file:a + steps: + - unmarshal: + json: {} + - to: http://localhost:8080/stock?sku=A1 + - to: + uri: http://localhost:8080/stock + parameters: + httpMethod: GET + - route: + from: + uri: file:b + steps: + - unmarshal: + json: {} + - to: + uri: http://localhost:8080/stock + parameters: + sku: A1 + - route: + from: + uri: timer:tick + steps: + - setBody: + constant: resource:file:stock.json + - unmarshal: + json: {} + - setHeader: + name: CamelHttpMethod + constant: GET + - to: http://localhost:8080/stock + - route: + from: + uri: direct:c + steps: + - unmarshal: + json: {} + - to: http://localhost:8080/stock + """; + assertThat(lines(yaml)).isEmpty(); + } + + @Test + public void testParsedDataAsTheReply() { + // what the benchmark wrote: the stock is looked up in the parsed file, and the Map found is the reply + String yaml = """ + - route: + id: stock + from: + uri: platform-http:/stock + steps: + - setBody: + constant: resource:file:stock.json + - unmarshal: + json: {} + - setBody: + groovy: body.find { it.sku == headers.sku } + """; + assertThat(lines(yaml)).singleElement().satisfies(m -> { + assertThat(m).startsWith("Line 11: "); + assertThat(m).contains("route stock: the body at the end of the route is the reply to the HTTP request"); + assertThat(m).contains("unmarshal: json turns the text into data, and groovy body.find { ... } keeps it data"); + assertThat(m).contains("add marshal: json as the last step"); + }); + } + + @Test + public void testTheCalledRouteReturnsData() { + String yaml = """ + - route: + from: + uri: direct:lookup + steps: + - setBody: + constant: resource:file:stock.json + - unmarshal: + json: {} + - route: + id: stock + from: + uri: platform-http:/stock + steps: + - to: direct:lookup + """; + assertThat(lines(yaml)).singleElement().satisfies(m -> { + assertThat(m).startsWith("Line 14: "); + assertThat(m).contains("to: direct:lookup returns it as data (unmarshal: json turns the text into data)"); + }); + } + + @Test + public void testDataMadeTextAgainIsFine() { + String yaml = """ + - route: + from: + uri: platform-http:/stock + steps: + - setBody: + constant: resource:file:stock.json + - unmarshal: + json: {} + - setBody: + groovy: body.find { it.sku == headers.sku } + - marshal: + json: {} + - route: + from: + uri: file:orders + steps: + - unmarshal: + csv: {} + - setBody: + simple: "${body.size()} rows" + - to: file:report + """; + assertThat(lines(yaml)).isEmpty(); + } + + @Test + public void testWhatIsNotCertainlyDataSaysNothing() { + // a field of the element picked, an object of a class, an iterator, a bean: the body may be text, or not data + String yaml = """ + - route: + from: + uri: platform-http:/qty + steps: + - setBody: + constant: resource:file:stock.json + - unmarshal: + json: {} + - setBody: + groovy: body.find { it.sku == headers.sku }?.qty + - route: + from: + uri: platform-http:/order + steps: + - unmarshal: + json: + unmarshalType: com.example.Order + - route: + from: + uri: file:in + steps: + - unmarshal: + csv: + lazyLoad: true + - to: file:out + - route: + from: + uri: platform-http:/total + steps: + - unmarshal: + json: {} + - bean: + ref: totals + """; + assertThat(lines(yaml)).isEmpty(); + } + + @Test + public void testGroovyCallsThatKeepData() { + assertThat(TextBodyFlow.keepsData(groovy("body.findAll { it.qty > 0 }"))).isEqualTo("body.findAll { ... }"); + assertThat(TextBodyFlow.keepsData(groovy("body.find { line -> line.sku == headers.sku }"))) + .isEqualTo("body.find { ... }"); + assertThat(TextBodyFlow.keepsData(groovy("body.collect { [sku: it.sku, total: it.qty * it.price] }"))) + .isEqualTo("body.collect { ... }"); + // what follows the closure, or an element whose fields are not read, may be anything + assertThat(TextBodyFlow.keepsData(groovy("body.find { it.sku == 'A1' }.qty"))).isNull(); + assertThat(TextBodyFlow.keepsData(groovy("body.find { it == 'A1' }"))).isNull(); + assertThat(TextBodyFlow.keepsData(groovy("body.size()"))).isNull(); + } + + private static JsonNode groovy(String script) { + ObjectNode node = JsonNodeFactory.instance.objectNode(); + node.putObject("expression").put("groovy", script); + return node; + } + private static List<String> lines(String yaml) { try { return YamlValidator.describeAll(yaml, validator.validate(yaml));
