This is an automated email from the ASF dual-hosted git repository.
Croway pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new daa86560aa2a CAMEL-24683: camel-groovy - pretty print groovyJson in a
single pass with a DefaultJsonGenerator subclass
daa86560aa2a is described below
commit daa86560aa2ac1e4dbb4d9ea0b8b6a5299480c05
Author: croway <[email protected]>
AuthorDate: Fri Sep 11 11:19:26 2026 +0200
CAMEL-24683: camel-groovy - pretty print groovyJson in a single pass with a
DefaultJsonGenerator subclass
With the default prettyPrint=true the data format rendered the document
with JsonOutput.toJson and
then re-lexed the whole text with JsonOutput.prettyPrint (a regex
tokenizer): 60.8 us per 1 KB
document against 1.9 us for Jackson, 55.6 ms and 243 MB allocated per 1 MB
document.
PrettyJsonGenerator extends groovy's DefaultJsonGenerator and only
overrides the container methods
(writeMap, writeIterator, writeArray) to add newlines and the four-space
indentation of
JsonOutput.prettyPrint; every scalar, date, enum and POJO is still
formatted by groovy. The output
is byte-identical to JsonOutput.prettyPrint(JsonOutput.toJson(doc)) on 14
documents (empty
containers, nesting, unicode and escapes, nulls, doubles and BigDecimal,
Date/UUID/enum, primitive
and object arrays, sets, top-level scalars, a POJO). Bytes are written with
the exchange charset
(UTF-8 default) instead of the platform default.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
---
.../camel/groovy/json/GroovyJSonlDataFormat.java | 21 ++--
.../camel/groovy/json/PrettyJsonGenerator.java | 138 ++++++++++++++++++++
.../groovy/json/GroovyJsonPrettyPrintTest.java | 139 +++++++++++++++++++++
3 files changed, 290 insertions(+), 8 deletions(-)
diff --git
a/components/camel-groovy/src/main/java/org/apache/camel/groovy/json/GroovyJSonlDataFormat.java
b/components/camel-groovy/src/main/java/org/apache/camel/groovy/json/GroovyJSonlDataFormat.java
index 2cf5f184d414..14d6675d6289 100644
---
a/components/camel-groovy/src/main/java/org/apache/camel/groovy/json/GroovyJSonlDataFormat.java
+++
b/components/camel-groovy/src/main/java/org/apache/camel/groovy/json/GroovyJSonlDataFormat.java
@@ -28,6 +28,7 @@ import org.apache.camel.Exchange;
import org.apache.camel.spi.DataFormat;
import org.apache.camel.spi.DataFormatName;
import org.apache.camel.spi.annotations.Dataformat;
+import org.apache.camel.support.ExchangeHelper;
import org.apache.camel.support.service.ServiceSupport;
@Dataformat("groovyJson")
@@ -49,13 +50,13 @@ public class GroovyJSonlDataFormat extends ServiceSupport
implements DataFormat,
graph = NodeToJsonHelper.nodeToJson(n);
}
if (graph instanceof Map map) {
- serialize(map, stream);
+ serialize(exchange, map, stream);
} else {
// optional jackson 2.x or 3.x support
String type = graph.getClass().getName();
if (type.startsWith("com.fasterxml.jackson.databind") ||
type.startsWith("tools.jackson.databind")) {
var map =
exchange.getContext().getTypeConverter().convertTo(Map.class, exchange, graph);
- serialize(map, stream);
+ serialize(exchange, map, stream);
} else {
byte[] arr =
exchange.getContext().getTypeConverter().mandatoryConvertTo(byte[].class,
exchange, graph);
stream.write(arr);
@@ -74,12 +75,16 @@ public class GroovyJSonlDataFormat extends ServiceSupport
implements DataFormat,
return "groovyJson";
}
- private void serialize(Map map, OutputStream stream) throws IOException {
- String out = JsonOutput.toJson(map);
- if (prettyPrint) {
- out = JsonOutput.prettyPrint(out);
- }
- stream.write(out.getBytes());
+ private void serialize(Exchange exchange, Map map, OutputStream stream)
throws IOException {
+ String out = prettyPrint ? toPrettyJson(map) : JsonOutput.toJson(map);
+ stream.write(out.getBytes(ExchangeHelper.getCharset(exchange, true)));
}
+ /**
+ * Renders the value with the layout of {@link
JsonOutput#prettyPrint(String)} in a single pass, instead of
+ * generating compact JSON and lexing it again.
+ */
+ static String toPrettyJson(Object value) {
+ return new PrettyJsonGenerator().toJson(value);
+ }
}
diff --git
a/components/camel-groovy/src/main/java/org/apache/camel/groovy/json/PrettyJsonGenerator.java
b/components/camel-groovy/src/main/java/org/apache/camel/groovy/json/PrettyJsonGenerator.java
new file mode 100644
index 000000000000..4f6cbbb692b2
--- /dev/null
+++
b/components/camel-groovy/src/main/java/org/apache/camel/groovy/json/PrettyJsonGenerator.java
@@ -0,0 +1,138 @@
+/*
+ * 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.groovy.json;
+
+import java.lang.reflect.Array;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+import groovy.json.DefaultJsonGenerator;
+import groovy.json.JsonGenerator;
+import org.apache.groovy.json.internal.CharBuf;
+
+/**
+ * Groovy's {@link DefaultJsonGenerator} writing the layout of {@link
groovy.json.JsonOutput#prettyPrint(String)} in one
+ * pass: only the container methods are overridden to add newlines and a
four-space indentation, every scalar, date,
+ * enum and POJO is still formatted by Groovy. {@code JsonOutput.prettyPrint}
re-lexes the generated text with a
+ * regular-expression tokenizer, which cost 30x a Jackson pretty print.
+ * <p>
+ * Not thread safe: one instance per document.
+ */
+final class PrettyJsonGenerator extends DefaultJsonGenerator {
+
+ private static final char[] INDENT = " ".toCharArray();
+ private static final char[] SEPARATOR = ": ".toCharArray();
+
+ private int depth;
+
+ PrettyJsonGenerator() {
+ super(new JsonGenerator.Options());
+ }
+
+ @Override
+ protected void writeMap(Map<?, ?> map, CharBuf buffer) {
+ if (map.isEmpty()) {
+ empty('{', '}', buffer);
+ return;
+ }
+ buffer.addChar('{');
+ depth++;
+ boolean first = true;
+ for (Map.Entry<?, ?> entry : map.entrySet()) {
+ if (entry.getKey() == null) {
+ throw new IllegalArgumentException("Maps with null keys can't
be converted to JSON");
+ }
+ String key = entry.getKey().toString();
+ Object value = entry.getValue();
+ if (isExcludingValues(value) || isExcludingFieldsNamed(key)) {
+ continue;
+ }
+ if (first) {
+ first = false;
+ } else {
+ buffer.addChar(',');
+ }
+ newLine(buffer);
+ buffer.addJsonEscapedString(key,
disableUnicodeEscaping).addChars(SEPARATOR);
+ writeObject(key, value, buffer);
+ }
+ depth--;
+ newLine(buffer);
+ buffer.addChar('}');
+ }
+
+ @Override
+ protected void writeIterator(Iterator<?> iterator, CharBuf buffer) {
+ if (!iterator.hasNext()) {
+ empty('[', ']', buffer);
+ return;
+ }
+ buffer.addChar('[');
+ depth++;
+ boolean first = true;
+ while (iterator.hasNext()) {
+ Object item = iterator.next();
+ if (isExcludingValues(item)) {
+ continue;
+ }
+ if (first) {
+ first = false;
+ } else {
+ buffer.addChar(',');
+ }
+ newLine(buffer);
+ writeObject(item, buffer);
+ }
+ depth--;
+ newLine(buffer);
+ buffer.addChar(']');
+ }
+
+ @Override
+ protected void writeArray(Class<?> arrayClass, Object array, CharBuf
buffer) {
+ // groovy writes primitive arrays compactly; the pretty layout lists
every element on its own line
+ int length = Array.getLength(array);
+ List<Object> items = new ArrayList<>(length);
+ for (int i = 0; i < length; i++) {
+ Object item = Array.get(array, i);
+ items.add(item instanceof Character c ? String.valueOf(c) : item);
+ }
+ writeIterator(items.iterator(), buffer);
+ }
+
+ /**
+ * An empty container is an opening bracket, an indented blank line and
the closing bracket, as
+ * {@code JsonOutput.prettyPrint} writes it.
+ */
+ private void empty(char open, char close, CharBuf buffer) {
+ buffer.addChar(open);
+ depth++;
+ newLine(buffer);
+ depth--;
+ newLine(buffer);
+ buffer.addChar(close);
+ }
+
+ private void newLine(CharBuf buffer) {
+ buffer.addChar('\n');
+ for (int i = 0; i < depth; i++) {
+ buffer.addChars(INDENT);
+ }
+ }
+}
diff --git
a/components/camel-groovy/src/test/java/org/apache/camel/groovy/json/GroovyJsonPrettyPrintTest.java
b/components/camel-groovy/src/test/java/org/apache/camel/groovy/json/GroovyJsonPrettyPrintTest.java
new file mode 100644
index 000000000000..64ecf969a1c5
--- /dev/null
+++
b/components/camel-groovy/src/test/java/org/apache/camel/groovy/json/GroovyJsonPrettyPrintTest.java
@@ -0,0 +1,139 @@
+/*
+ * 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.groovy.json;
+
+import java.io.ByteArrayOutputStream;
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.UUID;
+import java.util.stream.Stream;
+
+import groovy.json.JsonOutput;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultExchange;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * The single pass pretty printer renders exactly what {@link
JsonOutput#prettyPrint(String)} renders.
+ */
+public class GroovyJsonPrettyPrintTest {
+
+ static Stream<Arguments> documents() {
+ return Stream.of(
+ Arguments.of("empty map", map()),
+ Arguments.of("empty list", list()),
+ Arguments.of("nested", map("a", 1, "b", map(), "c", list(),
"d", list(1, 2.5, "x\"y", null, true),
+ "e", map("f", map("g", list(map("h", "ü€")))))),
+ Arguments.of("list of maps", list(map("a", 1), map("b",
list()), map())),
+ Arguments.of("strings", map("s", "line\nbreak\ttab/slash\\back
\"quoted\" 'single'", "empty", "",
+ "unicode", "é中😀", "k\"ey", "v", "ü", "€")),
+ Arguments.of("numbers", map("i", 1, "l", Long.MAX_VALUE, "d",
1.0d, "f", 1.5f, "big", 1.2345678901234567E19,
+ "neg", -1.5e-7, "bd", new BigDecimal("1.10"), "zero",
0, "nd", -0.0d)),
+ Arguments.of("scalars", map("n", null, "t", true, "f", false,
"c", 'c', "date", new Date(0),
+ "uuid",
UUID.fromString("11111111-2222-3333-4444-555555555555"), "en",
Thread.State.NEW)),
+ Arguments.of("arrays and sets", map("arr", new String[] { "a",
"b" }, "empty", new Object[0],
+ "ints", new int[] { 1, 2 }, "set", new
LinkedHashSet<>(list(1, 2)),
+ "sorted", new TreeMap<>(map("z", 1, "a", 2)))),
+ Arguments.of("deep nesting", map("nested", list(list(list()),
list(map()), map("x", list(list(1)))))),
+ Arguments.of("top level list", list(1, "two", map("three", 3),
list(4))),
+ Arguments.of("top level string", "top"),
+ Arguments.of("top level number", 42),
+ Arguments.of("top level null", null),
+ Arguments.of("pojo", map("pojo", new Book("Dune", 1965),
"pojos", list(new Book("Emma", 1815)))));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("documents")
+ public void testSameOutputAsGroovyPrettyPrint(String name, Object
document) {
+ String expected = JsonOutput.prettyPrint(JsonOutput.toJson(document));
+ assertEquals(expected, GroovyJSonlDataFormat.toPrettyJson(document));
+ }
+
+ @Test
+ public void testNullKey() {
+ Map<String, Object> doc = new LinkedHashMap<>();
+ doc.put(null, "x");
+ assertThrows(IllegalArgumentException.class, () ->
JsonOutput.toJson(doc));
+ assertThrows(IllegalArgumentException.class, () ->
GroovyJSonlDataFormat.toPrettyJson(doc));
+ }
+
+ @Test
+ public void testMarshalUsesExchangeCharset() throws Exception {
+ try (CamelContext context = new DefaultCamelContext()) {
+ context.start();
+ GroovyJSonlDataFormat df = new GroovyJSonlDataFormat();
+ Map<String, Object> doc = map("name", "Jürgen");
+
+ Exchange exchange = new DefaultExchange(context);
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ df.marshal(exchange, doc, bos);
+ assertEquals(JsonOutput.prettyPrint(JsonOutput.toJson(doc)),
bos.toString(StandardCharsets.UTF_8));
+
+ df.setPrettyPrint(false);
+ exchange.setProperty(Exchange.CHARSET_NAME, "UTF-16BE");
+ bos = new ByteArrayOutputStream();
+ df.marshal(exchange, doc, bos);
+ assertEquals(JsonOutput.toJson(doc),
bos.toString(StandardCharsets.UTF_16BE));
+ }
+ }
+
+ private static Map<String, Object> map(Object... keyValues) {
+ Map<String, Object> map = new LinkedHashMap<>();
+ for (int i = 0; i < keyValues.length; i += 2) {
+ map.put((String) keyValues[i], keyValues[i + 1]);
+ }
+ return map;
+ }
+
+ private static List<Object> list(Object... values) {
+ return new ArrayList<>(Arrays.asList(values));
+ }
+
+ public static class Book {
+ private final String title;
+ private final int year;
+
+ Book(String title, int year) {
+ this.title = title;
+ this.year = year;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public int getYear() {
+ return year;
+ }
+ }
+}