[serde-jackson] Support Jackson readers and writers

This commit is contained in:
2025-08-03 18:42:41 +02:00
parent f694672d5f
commit c58b806bcf
10 changed files with 687 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
plugins {
id("de.siphalor.tweed5.base-module")
id("de.siphalor.tweed5.minecraft.mod.dummy")
id("de.siphalor.tweed5.shadow.explicit")
}
dependencies {
implementation(project(":tweed5-serde-api"))
implementation(libs.jackson.core)
shadowOnly(libs.jackson.core)
}
tasks.shadowJar {
relocate("com.fasterxml.jackson.core", "de.siphalor.tweed5.data.jackson.shadowed.jackson.core")
}

View File

@@ -0,0 +1,2 @@
minecraft.mod.name = Tweed 5 Jackson
minecraft.mod.description = Tweed 5 module that adds support for reading and writing using the Jackson library.

View File

@@ -0,0 +1,265 @@
package de.siphalor.tweed5.data.jackson;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import de.siphalor.tweed5.dataapi.api.TweedDataReadException;
import de.siphalor.tweed5.dataapi.api.TweedDataReader;
import de.siphalor.tweed5.dataapi.api.TweedDataToken;
import de.siphalor.tweed5.dataapi.api.TweedDataTokens;
import org.jspecify.annotations.Nullable;
import java.io.IOException;
import java.util.ArrayDeque;
import java.util.Deque;
public class JacksonReader implements TweedDataReader {
private final JsonParser parser;
private final Deque<Context> contextStack = new ArrayDeque<>();
private @Nullable TweedDataToken peekedToken;
public JacksonReader(JsonParser parser) {
this.parser = parser;
this.contextStack.push(Context.VALUE);
}
@Override
public TweedDataToken peekToken() throws TweedDataReadException {
if (peekedToken == null) {
peekedToken = nextToken();
}
return peekedToken;
}
@Override
public TweedDataToken readToken() throws TweedDataReadException {
if (peekedToken != null) {
TweedDataToken token = peekedToken;
peekedToken = null;
return token;
}
return nextToken();
}
private TweedDataToken nextToken() throws TweedDataReadException {
try {
JsonToken jsonToken = parser.nextToken();
switch (jsonToken) {
case START_ARRAY: {
TweedDataToken token = wrapToken(TweedDataTokens.getListStart());
contextStack.push(Context.LIST);
return token;
}
case END_ARRAY: {
popContext(Context.LIST);
TweedDataToken token = wrapToken(TweedDataTokens.getListEnd());
afterValueRead();
return token;
}
case START_OBJECT: {
TweedDataToken token = wrapToken(TweedDataTokens.getMapStart());
contextStack.push(Context.MAP);
return token;
}
case END_OBJECT: {
popContext(Context.MAP);
TweedDataToken token = wrapToken(TweedDataTokens.getMapEnd());
afterValueRead();
return token;
}
case FIELD_NAME:
contextStack.push(Context.MAP_ENTRY_VALUE);
return TweedDataTokens.asMapEntryKey(createStringToken(parser.getText()));
case VALUE_NULL: {
TweedDataToken token = wrapToken(TweedDataTokens.getNull());
afterValueRead();
return token;
}
case VALUE_TRUE: {
TweedDataToken token = wrapToken(createBooleanToken(true));
afterValueRead();
return token;
}
case VALUE_FALSE: {
TweedDataToken token = wrapToken(createBooleanToken(false));
afterValueRead();
return token;
}
case VALUE_NUMBER_INT: {
long longValue = parser.getLongValue();
TweedDataToken token = wrapToken(new TweedDataToken() {
@Override
public boolean canReadAsByte() {
return longValue >= Byte.MIN_VALUE && longValue <= Byte.MAX_VALUE;
}
@Override
public byte readAsByte() {
return (byte) longValue;
}
@Override
public boolean canReadAsShort() {
return longValue >= Short.MIN_VALUE && longValue <= Short.MAX_VALUE;
}
@Override
public short readAsShort() {
return (short) longValue;
}
@Override
public boolean canReadAsInt() {
return longValue >= Integer.MIN_VALUE && longValue <= Integer.MAX_VALUE;
}
@Override
public int readAsInt() {
return (int) longValue;
}
@Override
public boolean canReadAsLong() {
return true;
}
@Override
public long readAsLong() {
return longValue;
}
});
afterValueRead();
return token;
}
case VALUE_NUMBER_FLOAT: {
float floatValue = parser.getFloatValue();
double doubleValue = parser.getDoubleValue();
TweedDataToken token = wrapToken(new TweedDataToken() {
@Override
public boolean canReadAsFloat() {
return true;
}
@Override
public float readAsFloat() {
return floatValue;
}
@Override
public boolean canReadAsDouble() {
return true;
}
@Override
public double readAsDouble() {
return doubleValue;
}
});
afterValueRead();
return token;
}
case VALUE_STRING: {
TweedDataToken token = wrapToken(createStringToken(parser.getText()));
afterValueRead();
return token;
}
case VALUE_EMBEDDED_OBJECT:
throw TweedDataReadException.builder()
.message("Encountered unsupported embedded object at " + parser.currentLocation())
.build();
case NOT_AVAILABLE:
throw TweedDataReadException.builder()
.message("Encountered unexpected NOT_AVAILABLE token at " + parser.currentLocation())
.build();
default:
throw TweedDataReadException.builder()
.message("Encountered unexpected token " + jsonToken + " at " + parser.currentLocation())
.build();
}
} catch (IOException e) {
throw TweedDataReadException.builder()
.message("Error reading data using jackson at " + parser.currentLocation())
.cause(e)
.build();
}
}
private TweedDataToken wrapToken(TweedDataToken token) throws TweedDataReadException {
Context context = peekContext();
switch (context) {
case LIST:
return TweedDataTokens.asListValue(token);
case MAP_ENTRY_VALUE:
return TweedDataTokens.asMapEntryValue(token);
case VALUE:
return token;
default:
throw TweedDataReadException.builder()
.message("Encountered token " + token + " in unexpected context: " + context)
.build();
}
}
private void afterValueRead() throws TweedDataReadException {
Context context = peekContext();
switch (context) {
case MAP_ENTRY_VALUE:
case VALUE:
contextStack.pop();
}
}
private Context peekContext() throws TweedDataReadException {
Context context = contextStack.peek();
if (context == null) {
throw TweedDataReadException.builder()
.message("Tried to read context but currently not in any context")
.build();
}
return context;
}
private void popContext(Context expectedContext) throws TweedDataReadException {
Context context = contextStack.pop();
if (context != expectedContext) {
throw TweedDataReadException.builder()
.message("Unexpected context " + context + " when popping " + expectedContext)
.build();
}
}
private TweedDataToken createStringToken(String value) {
return new TweedDataToken() {
@Override
public boolean canReadAsString() {
return true;
}
@Override
public String readAsString() {
return value;
}
};
}
private TweedDataToken createBooleanToken(boolean value) {
return new TweedDataToken() {
@Override
public boolean canReadAsBoolean() {
return true;
}
@Override
public boolean readAsBoolean() {
return value;
}
};
}
private enum Context {
VALUE,
LIST,
MAP,
MAP_ENTRY_VALUE,
}
}

View File

@@ -0,0 +1,229 @@
package de.siphalor.tweed5.data.jackson;
import com.fasterxml.jackson.core.JsonGenerator;
import de.siphalor.tweed5.dataapi.api.TweedDataVisitor;
import de.siphalor.tweed5.dataapi.api.TweedDataWriteException;
import de.siphalor.tweed5.dataapi.api.decoration.TweedDataCommentDecoration;
import de.siphalor.tweed5.dataapi.api.decoration.TweedDataDecoration;
import org.jspecify.annotations.Nullable;
import java.io.IOException;
import java.util.ArrayDeque;
import java.util.Deque;
public class JacksonWriter implements TweedDataVisitor {
private final JsonGenerator generator;
private final CommentWriteMode commentWriteMode;
private final Deque<Context> contextStack = new ArrayDeque<>();
private @Nullable String deferredFieldComment;
public JacksonWriter(JsonGenerator generator, CommentWriteMode commentWriteMode) {
this.generator = generator;
this.commentWriteMode = commentWriteMode;
this.contextStack.push(Context.VALUE);
}
@Override
public void visitNull() {
try {
generator.writeNull();
afterValueVisited();
} catch (IOException e) {
throw createWriteExceptionForIOException(e);
}
}
@Override
public void visitBoolean(boolean value) {
try {
generator.writeBoolean(value);
afterValueVisited();
} catch (IOException e) {
throw createWriteExceptionForIOException(e);
}
}
@Override
public void visitByte(byte value) {
try {
generator.writeNumber(value);
afterValueVisited();
} catch (IOException e) {
throw createWriteExceptionForIOException(e);
}
}
@Override
public void visitShort(short value) {
try {
generator.writeNumber(value);
afterValueVisited();
} catch (IOException e) {
throw createWriteExceptionForIOException(e);
}
}
@Override
public void visitInt(int value) {
try {
generator.writeNumber(value);
afterValueVisited();
} catch (IOException e) {
throw createWriteExceptionForIOException(e);
}
}
@Override
public void visitLong(long value) {
try {
generator.writeNumber(value);
afterValueVisited();
} catch (IOException e) {
throw createWriteExceptionForIOException(e);
}
}
@Override
public void visitFloat(float value) {
try {
generator.writeNumber(value);
afterValueVisited();
} catch (IOException e) {
throw createWriteExceptionForIOException(e);
}
}
@Override
public void visitDouble(double value) {
try {
generator.writeNumber(value);
afterValueVisited();
} catch (IOException e) {
throw createWriteExceptionForIOException(e);
}
}
@Override
public void visitString(String value) {
try {
generator.writeString(value);
afterValueVisited();
} catch (IOException e) {
throw createWriteExceptionForIOException(e);
}
}
@Override
public void visitListStart() {
try {
generator.writeStartArray();
contextStack.push(Context.LIST);
} catch (IOException e) {
throw createWriteExceptionForIOException(e);
}
}
@Override
public void visitListEnd() {
try {
generator.writeEndArray();
popContext(Context.LIST);
afterValueVisited();
} catch (IOException e) {
throw createWriteExceptionForIOException(e);
}
}
@Override
public void visitMapStart() {
try {
generator.writeStartObject();
contextStack.push(Context.MAP);
} catch (IOException e) {
throw createWriteExceptionForIOException(e);
}
}
@Override
public void visitMapEntryKey(String key) {
try {
if (deferredFieldComment != null) {
generator.writeFieldName(key + "__comment");
generator.writeString(deferredFieldComment);
deferredFieldComment = null;
}
generator.writeFieldName(key);
contextStack.push(Context.VALUE);
} catch (IOException e) {
throw createWriteExceptionForIOException(e);
}
}
@Override
public void visitMapEnd() {
try {
generator.writeEndObject();
popContext(Context.MAP);
afterValueVisited();
} catch (IOException e) {
throw createWriteExceptionForIOException(e);
}
}
@Override
public void visitDecoration(TweedDataDecoration decoration) {
if (decoration instanceof TweedDataCommentDecoration) {
switch (commentWriteMode) {
case NONE:
break;
case MAP_ENTRIES:
if (contextStack.peek() == Context.MAP) {
if (deferredFieldComment == null) {
deferredFieldComment = ((TweedDataCommentDecoration) decoration).comment();
} else {
deferredFieldComment += "\n" + ((TweedDataCommentDecoration) decoration).comment();
}
}
break;
case DOUBLE_SLASHES:
try {
generator.writeRaw("// ");
generator.writeRaw(((TweedDataCommentDecoration) decoration).comment());
generator.writeRaw("\n");
} catch (IOException e) {
throw createWriteExceptionForIOException(e);
}
}
}
}
private void afterValueVisited() {
if (contextStack.peek() == Context.VALUE) {
contextStack.pop();
}
}
private void popContext(Context expectedContext) {
Context context = contextStack.pop();
if (context != expectedContext) {
throw new IllegalStateException("Unexpected context " + context + " when popping " + expectedContext);
}
}
private TweedDataWriteException createWriteExceptionForIOException(IOException e) {
throw new TweedDataWriteException("Error writing data using jackson at " + generator.getOutputContext(), e);
}
public enum CommentWriteMode {
NONE,
MAP_ENTRIES,
DOUBLE_SLASHES,
}
private enum Context {
VALUE,
LIST,
MAP,
}
}

View File

@@ -0,0 +1,4 @@
@NullMarked
package de.siphalor.tweed5.data.jackson;
import org.jspecify.annotations.NullMarked;

View File

@@ -0,0 +1,78 @@
package de.siphalor.tweed5.data.jackson;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.StreamReadFeature;
import lombok.SneakyThrows;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class JacksonReaderTest {
@SneakyThrows
@Test
void complex() {
var inputStream = new ByteArrayInputStream("""
{
"first": [
[ 1 ]
],
"second": {
"test": "Hello World!"
}
}
""".getBytes(StandardCharsets.UTF_8));
try (var parser = JsonFactory.builder().enable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION).build().createParser(inputStream)) {
var reader = new JacksonReader(parser);
var token = reader.peekToken();
assertThat(token.isMapStart()).isTrue();
token = reader.readToken();
assertThat(token.isMapStart()).isTrue();
token = reader.readToken();
assertThat(token.isMapEntryKey()).isTrue();
assertThat(token.canReadAsString()).isTrue();
assertThat(token.readAsString()).isEqualTo("first");
token = reader.readToken();
assertThat(token.isMapEntryValue()).isTrue();
assertThat(token.isListStart()).isTrue();
token = reader.readToken();
assertThat(token.isMapEntryValue()).isFalse();
assertThat(token.isListValue()).isTrue();
assertThat(token.isListStart()).isTrue();
token = reader.readToken();
assertThat(token.isListValue()).isTrue();
assertThat(token.canReadAsInt()).isTrue();
assertThat(token.readAsInt()).isEqualTo(1);
token = reader.readToken();
assertThat(token.isListValue()).isTrue();
assertThat(token.isListEnd()).isTrue();
token = reader.readToken();
assertThat(token.isListValue()).isFalse();
assertThat(token.isMapEntryValue()).isTrue();
assertThat(token.isListEnd()).isTrue();
token = reader.readToken();
assertThat(token.isMapEntryKey()).isTrue();
assertThat(token.canReadAsString()).isTrue();
assertThat(token.readAsString()).isEqualTo("second");
token = reader.readToken();
assertThat(token.isMapEntryValue()).isTrue();
assertThat(token.isMapStart()).isTrue();
token = reader.readToken();
assertThat(token.isMapEntryValue()).isFalse();
assertThat(token.isMapEntryKey()).isTrue();
assertThat(token.canReadAsString()).isTrue();
assertThat(token.readAsString()).isEqualTo("test");
token = reader.readToken();
assertThat(token.isMapEntryValue()).isTrue();
assertThat(token.canReadAsString()).isTrue();
assertThat(token.readAsString()).isEqualTo("Hello World!");
token = reader.readToken();
assertThat(token.isMapEnd()).isTrue();
token = reader.readToken();
assertThat(token.isMapEnd()).isTrue();
}
}
}

View File

@@ -0,0 +1,77 @@
package de.siphalor.tweed5.data.jackson;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter;
import com.fasterxml.jackson.core.util.Separators;
import de.siphalor.tweed5.dataapi.api.decoration.TweedDataCommentDecoration;
import lombok.SneakyThrows;
import org.junit.jupiter.api.Test;
import java.io.StringWriter;
import static org.assertj.core.api.Assertions.assertThat;
class JacksonWriterTest {
@SneakyThrows
@Test
void object() {
var stringWriter = new StringWriter();
try (var generator = JsonFactory.builder().build().createGenerator(stringWriter)) {
generator.setPrettyPrinter(
new DefaultPrettyPrinter()
.withSeparators(new Separators().withObjectFieldValueSpacing(Separators.Spacing.AFTER))
);
var writer = new JacksonWriter(generator, JacksonWriter.CommentWriteMode.MAP_ENTRIES);
writer.visitMapStart();
writer.visitDecoration((TweedDataCommentDecoration) () -> "Hello\nWorld");
writer.visitDecoration((TweedDataCommentDecoration) () -> "!");
writer.visitMapEntryKey("test");
writer.visitInt(1234);
writer.visitMapEnd();
}
assertThat(stringWriter.toString()).isEqualTo("""
{
"test__comment": "Hello\\nWorld\\n!",
"test": 1234
}""");
}
@SneakyThrows
@Test
void complex() {
var stringWriter = new StringWriter();
try (var generator = JsonFactory.builder().build().createGenerator(stringWriter)) {
generator.setPrettyPrinter(
new DefaultPrettyPrinter()
.withSeparators(new Separators().withObjectFieldValueSpacing(Separators.Spacing.AFTER))
);
var writer = new JacksonWriter(generator, JacksonWriter.CommentWriteMode.MAP_ENTRIES);
writer.visitMapStart();
writer.visitMapEntryKey("first");
writer.visitListStart();
writer.visitInt(1);
writer.visitDecoration((TweedDataCommentDecoration) () -> "not written");
writer.visitInt(2);
writer.visitListEnd();
writer.visitDecoration((TweedDataCommentDecoration) () -> "second object");
writer.visitMapEntryKey("second");
writer.visitMapStart();
writer.visitDecoration((TweedDataCommentDecoration) () -> "inner entry");
writer.visitMapEntryKey("inner");
writer.visitBoolean(true);
writer.visitMapEnd();
writer.visitMapEnd();
}
assertThat(stringWriter.toString()).isEqualTo("""
{
"first": [ 1, 2 ],
"second__comment": "second object",
"second": {
"inner__comment": "inner entry",
"inner": true
}
}""");
}
}