This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch worktree-CAMEL-23593-normalize-rest-routeconfig in repository https://gitbox.apache.org/repos/asf/camel.git
commit f62791cb445fb9e93e6d9a86d771a915d61a3401 Author: Claus Ibsen <[email protected]> AuthorDate: Thu May 21 10:14:29 2026 +0200 CAMEL-23596: camel-yaml-io - Add YamlPrinter for direct YAML serialization Add a lightweight YamlPrinter that serializes Map/Collection structures to block-style YAML text without any Jackson dependency. This is the foundation for replacing the hand-written YamlWriter with a generated direct YAML writer. Co-Authored-By: Claude <[email protected]> --- .../java/org/apache/camel/yaml/io/YamlPrinter.java | 190 +++++++++++++++++ .../org/apache/camel/yaml/io/YamlPrinterTest.java | 231 +++++++++++++++++++++ 2 files changed, 421 insertions(+) diff --git a/core/camel-yaml-io/src/main/java/org/apache/camel/yaml/io/YamlPrinter.java b/core/camel-yaml-io/src/main/java/org/apache/camel/yaml/io/YamlPrinter.java new file mode 100644 index 000000000000..cfaf60b8c5af --- /dev/null +++ b/core/camel-yaml-io/src/main/java/org/apache/camel/yaml/io/YamlPrinter.java @@ -0,0 +1,190 @@ +/* + * 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.yaml.io; + +import java.util.Collection; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Serializes {@link Map} / {@link Collection} structures (typically from Camel's {@code JsonObject} / + * {@code JsonArray}) to block-style YAML text. Produces a constrained YAML subset: no anchors, aliases, flow mappings, + * tags, or multi-document streams. + */ +public final class YamlPrinter { + + private static final String INDENT = " "; + private static final Pattern NUMBER_PATTERN = Pattern.compile("-?(0|[1-9]\\d*)(\\.\\d+)?([eE][+-]?\\d+)?"); + + private YamlPrinter() { + } + + public static String print(Collection<?> roots) { + StringBuilder sb = new StringBuilder(); + writeSequenceItems(sb, roots, 0, true); + if (!sb.isEmpty() && sb.charAt(sb.length() - 1) != '\n') { + sb.append('\n'); + } + return sb.toString(); + } + + private static void writeSequenceItems(StringBuilder sb, Collection<?> items, int indent, boolean topLevel) { + for (Object item : items) { + writeIndent(sb, indent); + sb.append("- "); + if (item instanceof Map<?, ?> map) { + if (map.isEmpty()) { + sb.append("{}\n"); + } else { + writeMappingEntries(sb, map, indent + 1, true); + } + } else if (item instanceof Collection<?> col) { + sb.append('\n'); + writeSequenceItems(sb, col, indent + 1, false); + } else { + writeScalar(sb, item); + sb.append('\n'); + } + } + } + + @SuppressWarnings("unchecked") + private static void writeMappingEntries(StringBuilder sb, Map<?, ?> map, int indent, boolean firstInline) { + boolean first = true; + for (Map.Entry<?, ?> entry : map.entrySet()) { + String key = String.valueOf(entry.getKey()); + Object value = entry.getValue(); + + if (first && firstInline) { + first = false; + } else { + writeIndent(sb, indent); + } + + sb.append(key).append(':'); + + if (value instanceof Map<?, ?> childMap) { + if (childMap.isEmpty()) { + sb.append(" {}\n"); + } else { + sb.append('\n'); + writeMappingEntries(sb, childMap, indent + 1, false); + } + } else if (value instanceof Collection<?> col) { + sb.append('\n'); + writeSequenceItems(sb, col, indent + 1, false); + } else { + sb.append(' '); + if (value instanceof String s && s.contains("\n")) { + writeBlockScalar(sb, s, indent + 1); + } else { + writeScalar(sb, value); + sb.append('\n'); + } + } + } + } + + private static void writeBlockScalar(StringBuilder sb, String value, int indent) { + if (value.endsWith("\n")) { + sb.append("|\n"); + } else { + sb.append("|-\n"); + } + for (String line : value.split("\n", -1)) { + if (line.isEmpty()) { + sb.append('\n'); + } else { + writeIndent(sb, indent); + sb.append(line).append('\n'); + } + } + } + + private static void writeScalar(StringBuilder sb, Object value) { + if (value == null) { + sb.append("null"); + } else if (value instanceof Boolean) { + sb.append(value); + } else if (value instanceof Number) { + sb.append(value); + } else { + String s = String.valueOf(value); + if (needsQuoting(s)) { + sb.append('"'); + sb.append(s.replace("\\", "\\\\").replace("\"", "\\\"")); + sb.append('"'); + } else { + sb.append(s); + } + } + } + + static boolean needsQuoting(String s) { + if (s.isEmpty()) { + return true; + } + + char first = s.charAt(0); + if (first == ' ' || first == '\t' || first == '-' || first == '?' || first == '*' + || first == '&' || first == '!' || first == '%' || first == '@' || first == '`' + || first == '\'' || first == '"' || first == '{' || first == '[' || first == '>' + || first == '|' || first == '#' || first == '$') { + return true; + } + + if (s.charAt(s.length() - 1) == ' ' || s.charAt(s.length() - 1) == '\t') { + return true; + } + + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == ':' && i + 1 < s.length() && s.charAt(i + 1) == ' ') { + return true; + } + if (c == '#' && i > 0 && s.charAt(i - 1) == ' ') { + return true; + } + if (c == '\n') { + return true; + } + } + + if (isYamlKeyword(s)) { + return true; + } + + if (NUMBER_PATTERN.matcher(s).matches()) { + return true; + } + + return false; + } + + private static boolean isYamlKeyword(String s) { + return "true".equalsIgnoreCase(s) || "false".equalsIgnoreCase(s) + || "yes".equalsIgnoreCase(s) || "no".equalsIgnoreCase(s) + || "on".equalsIgnoreCase(s) || "off".equalsIgnoreCase(s) + || "null".equalsIgnoreCase(s) || "~".equals(s); + } + + private static void writeIndent(StringBuilder sb, int level) { + for (int i = 0; i < level; i++) { + sb.append(INDENT); + } + } +} diff --git a/core/camel-yaml-io/src/test/java/org/apache/camel/yaml/io/YamlPrinterTest.java b/core/camel-yaml-io/src/test/java/org/apache/camel/yaml/io/YamlPrinterTest.java new file mode 100644 index 000000000000..d07fbc82de04 --- /dev/null +++ b/core/camel-yaml-io/src/test/java/org/apache/camel/yaml/io/YamlPrinterTest.java @@ -0,0 +1,231 @@ +/* + * 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.yaml.io; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class YamlPrinterTest { + + @Test + void simpleRoute() { + var route = orderedMap( + "id", "myRoute", + "from", orderedMap( + "uri", "timer:yaml", + "parameters", orderedMap( + "period", 1234, + "includeMetadata", true), + "steps", List.of( + Map.of("log", orderedMap("message", "${body}"))))); + + var roots = List.of(Map.of("route", route)); + String yaml = YamlPrinter.print(roots); + + String expected = "- route:\n" + + " id: myRoute\n" + + " from:\n" + + " uri: timer:yaml\n" + + " parameters:\n" + + " period: 1234\n" + + " includeMetadata: true\n" + + " steps:\n" + + " - log:\n" + + " message: \"${body}\"\n"; + assertEquals(expected, yaml); + } + + @Test + void choiceWithWhenAndOtherwise() { + var when1 = orderedMap( + "expression", orderedMap( + "simple", orderedMap("expression", "${header.age} < 21")), + "steps", List.of( + Map.of("to", orderedMap("uri", "mock:young")))); + var when2 = orderedMap( + "expression", orderedMap( + "simple", orderedMap("expression", "${header.age} > 70")), + "steps", List.of( + Map.of("to", orderedMap("uri", "mock:senior")))); + var otherwise = orderedMap( + "steps", List.of( + Map.of("to", orderedMap("uri", "mock:work")))); + + var choice = orderedMap( + "when", List.of(when1, when2), + "otherwise", otherwise); + + var route = orderedMap( + "from", orderedMap( + "uri", "direct:start", + "steps", List.of(Map.of("choice", choice)))); + + String yaml = YamlPrinter.print(List.of(Map.of("route", route))); + + assertTrue(yaml.contains("- choice:")); + assertTrue(yaml.contains(" when:")); + assertTrue(yaml.contains(" otherwise:")); + assertTrue(yaml.contains(" - to:")); + } + + @Test + void emptyMapping() { + var roots = List.of( + Map.of("route", orderedMap( + "from", orderedMap( + "uri", "timer:foo", + "steps", List.of( + Map.of("marshal", orderedMap("csv", Map.of())), + Map.of("log", orderedMap("message", "${body}"))))))); + + String yaml = YamlPrinter.print(roots); + assertTrue(yaml.contains("csv: {}"), "Empty mapping should be inline {}"); + } + + @Test + void multiLineString() { + var roots = List.of( + Map.of("route", orderedMap( + "from", orderedMap( + "uri", "direct:start", + "steps", List.of( + Map.of("setBody", orderedMap( + "expression", orderedMap( + "constant", orderedMap( + "expression", "{\n key: '123'\n}"))))))))); + + String yaml = YamlPrinter.print(roots); + assertTrue(yaml.contains("|-"), "Multi-line without trailing newline should use |-"); + assertTrue(yaml.contains(" key: '123'")); + } + + @Test + void multiLineStringWithTrailingNewline() { + var roots = List.of( + Map.of("test", orderedMap("value", "line1\nline2\n"))); + + String yaml = YamlPrinter.print(roots); + assertTrue(yaml.contains("|\n"), "Multi-line with trailing newline should use |"); + } + + @Test + void quotingRules() { + assertTrue(YamlPrinter.needsQuoting(""), "empty string"); + assertTrue(YamlPrinter.needsQuoting("true"), "boolean true"); + assertTrue(YamlPrinter.needsQuoting("false"), "boolean false"); + assertTrue(YamlPrinter.needsQuoting("TRUE"), "boolean TRUE"); + assertTrue(YamlPrinter.needsQuoting("yes"), "boolean yes"); + assertTrue(YamlPrinter.needsQuoting("no"), "boolean no"); + assertTrue(YamlPrinter.needsQuoting("on"), "boolean on"); + assertTrue(YamlPrinter.needsQuoting("off"), "boolean off"); + assertTrue(YamlPrinter.needsQuoting("null"), "null"); + assertTrue(YamlPrinter.needsQuoting("~"), "tilde"); + assertTrue(YamlPrinter.needsQuoting("123"), "integer"); + assertTrue(YamlPrinter.needsQuoting("3.14"), "float"); + assertTrue(YamlPrinter.needsQuoting("-42"), "negative number"); + assertTrue(YamlPrinter.needsQuoting("foo: bar"), "contains colon-space"); + assertTrue(YamlPrinter.needsQuoting("foo #comment"), "contains space-hash"); + assertTrue(YamlPrinter.needsQuoting("- item"), "starts with dash-space"); + assertTrue(YamlPrinter.needsQuoting("*ref"), "starts with star"); + assertTrue(YamlPrinter.needsQuoting("&anchor"), "starts with ampersand"); + assertTrue(YamlPrinter.needsQuoting("{flow}"), "starts with brace"); + assertTrue(YamlPrinter.needsQuoting("[flow]"), "starts with bracket"); + + assertFalse(YamlPrinter.needsQuoting("hello"), "simple word"); + assertFalse(YamlPrinter.needsQuoting("Hello World"), "simple phrase"); + assertFalse(YamlPrinter.needsQuoting("timer:yaml"), "URI"); + assertFalse(YamlPrinter.needsQuoting("mock:result"), "simple URI"); + assertTrue(YamlPrinter.needsQuoting("${body}"), "starts with $"); + assertFalse(YamlPrinter.needsQuoting("foo.bar"), "dotted name"); + assertFalse(YamlPrinter.needsQuoting("my-route-id"), "dashed name"); + } + + @Test + void booleanAndNumberValues() { + var roots = List.of(Map.of("config", orderedMap( + "enabled", true, + "count", 42, + "name", "test"))); + + String yaml = YamlPrinter.print(roots); + assertTrue(yaml.contains("enabled: true")); + assertTrue(yaml.contains("count: 42")); + assertTrue(yaml.contains("name: test")); + } + + @Test + void multipleRoots() { + var route1 = orderedMap("id", "r1", "from", orderedMap("uri", "direct:a")); + var route2 = orderedMap("id", "r2", "from", orderedMap("uri", "direct:b")); + + String yaml = YamlPrinter.print(List.of( + Map.of("route", route1), + Map.of("route", route2))); + + long routeCount = yaml.lines().filter(l -> l.equals("- route:")).count(); + assertEquals(2, routeCount); + } + + @Test + void restWithEmptySteps() { + var roots = List.of( + Map.of("rest", orderedMap( + "path", "/api", + "steps", List.of( + Map.of("get", orderedMap("path", "/hello")))))); + + String yaml = YamlPrinter.print(roots); + assertTrue(yaml.contains("- rest:")); + assertTrue(yaml.contains(" path: /api")); + assertTrue(yaml.contains(" - get:")); + } + + @Test + void stringStartingWithDollarQuoted() { + var roots = List.of(Map.of("test", orderedMap( + "expr", "${header.age} < 21"))); + + String yaml = YamlPrinter.print(roots); + assertTrue(yaml.contains("expr: \"${header.age} < 21\""), + "Starts with $ — should be quoted: " + yaml); + } + + @Test + void stringWithColonSpaceQuoted() { + var roots = List.of(Map.of("test", orderedMap( + "value", "key: value"))); + + String yaml = YamlPrinter.print(roots); + assertTrue(yaml.contains("value: \"key: value\""), + "Colon-space must be quoted: " + yaml); + } + + private static Map<String, Object> orderedMap(Object... keysAndValues) { + Map<String, Object> map = new LinkedHashMap<>(); + for (int i = 0; i < keysAndValues.length; i += 2) { + map.put((String) keysAndValues[i], keysAndValues[i + 1]); + } + return map; + } +}
