com.gruelbox.transactionoutbox.DefaultInvocationSerializer Maven / Gradle / Ivy
package com.gruelbox.transactionoutbox;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
import com.google.gson.TypeAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import com.google.gson.stream.JsonWriter;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.lang.reflect.Modifier;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.text.ParseException;
import java.text.ParsePosition;
import java.time.DayOfWeek;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Month;
import java.time.MonthDay;
import java.time.Period;
import java.time.Year;
import java.time.YearMonth;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.UUID;
import lombok.Builder;
import lombok.extern.slf4j.Slf4j;
/**
* A locked-down serializer which supports a limited list of primitives and simple JDK value types.
* Only the following are supported:
*
*
* - {@link Invocation} itself
*
- Primitive types such as {@code int} or {@code double} or the boxed equivalents
*
- {@link String}
*
- {@link java.util.Date}
*
- {@link java.util.UUID}
*
- The {@code java.time} classes:
*
* - {@link java.time.DayOfWeek}
*
- {@link java.time.Duration}
*
- {@link java.time.Instant}
*
- {@link java.time.LocalDate}
*
- {@link java.time.LocalDateTime}
*
- {@link java.time.Month}
*
- {@link java.time.MonthDay}
*
- {@link java.time.Period}
*
- {@link java.time.Year}
*
- {@link java.time.YearMonth}
*
- {@link java.time.ZoneOffset}
*
- {@link java.time.DayOfWeek}
*
- {@link java.time.temporal.ChronoUnit}
*
* - Arrays specifically typed to one of the above types
*
- Any types specifically passed in, which must be GSON compatible.
*
*/
@Slf4j
public final class DefaultInvocationSerializer implements InvocationSerializer {
private final Gson gson;
@Builder
DefaultInvocationSerializer(Set> serializableTypes, Integer version) {
this.gson =
new GsonBuilder()
.registerTypeAdapter(
Invocation.class,
new InvocationJsonSerializer(
serializableTypes == null ? Set.of() : serializableTypes,
version == null ? 2 : version))
.registerTypeAdapter(Date.class, new UtcDateTypeAdapter())
.registerTypeAdapter(LocalDateTime.class, new LocalDateTimeTypeAdapter())
.registerTypeAdapter(Instant.class, new InstantTypeAdapter())
.registerTypeAdapter(Duration.class, new DurationTypeAdapter())
.registerTypeAdapter(LocalDate.class, new LocalDateTypeAdapter())
.registerTypeAdapter(MonthDay.class, new MonthDayTypeAdapter())
.registerTypeAdapter(Period.class, new PeriodTypeAdapter())
.registerTypeAdapter(Year.class, new YearTypeAdapter())
.registerTypeAdapter(YearMonth.class, new YearMonthAdapter())
.excludeFieldsWithModifiers(Modifier.TRANSIENT, Modifier.STATIC)
.create();
}
@Override
public void serializeInvocation(Invocation invocation, Writer writer) {
try {
gson.toJson(invocation, writer);
} catch (Exception e) {
throw new IllegalArgumentException("Cannot serialize " + invocation, e);
}
}
@Override
public Invocation deserializeInvocation(Reader reader) {
return gson.fromJson(reader, Invocation.class);
}
private static final class InvocationJsonSerializer
implements JsonSerializer, JsonDeserializer {
private final int version;
private final Map, String> classToName = new HashMap<>();
private final Map> nameToClass = new HashMap<>();
InvocationJsonSerializer(Set> serializableClasses, int version) {
this.version = version;
addClassPair(byte.class, "byte");
addClassPair(short.class, "short");
addClassPair(int.class, "int");
addClassPair(long.class, "long");
addClassPair(float.class, "float");
addClassPair(double.class, "double");
addClassPair(boolean.class, "boolean");
addClassPair(char.class, "char");
addClass(Byte.class);
addClass(Short.class);
addClass(Integer.class);
addClass(Long.class);
addClass(Float.class);
addClass(Double.class);
addClass(Boolean.class);
addClass(Character.class);
addClass(BigDecimal.class);
addClass(String.class);
addClass(Date.class);
addClass(UUID.class);
addClass(DayOfWeek.class);
addClass(Duration.class);
addClass(Instant.class);
addClass(LocalDate.class);
addClass(LocalDateTime.class);
addClass(Month.class);
addClass(MonthDay.class);
addClass(Period.class);
addClass(Year.class);
addClass(YearMonth.class);
addClass(ZoneOffset.class);
addClass(DayOfWeek.class);
addClass(ChronoUnit.class);
addClass(Transaction.class);
addClassPair(TransactionContextPlaceholder.class, "TransactionContext");
serializableClasses.forEach(clazz -> addClassPair(clazz, clazz.getName()));
}
private void addClass(Class> clazz) {
addClassPair(clazz, clazz.getSimpleName());
}
private void addClassPair(Class> clazz, String name) {
classToName.put(clazz, name);
nameToClass.put(name, clazz);
String arrayClassName = toArrayClassName(clazz);
Class> arrayClass = toClass(clazz.getClassLoader(), arrayClassName);
classToName.put(arrayClass, arrayClassName);
nameToClass.put(arrayClassName, arrayClass);
}
private String toArrayClassName(Class> clazz) {
if (clazz.isArray()) {
return "[" + clazz.getName();
} else if (clazz == boolean.class) {
return "[Z";
} else if (clazz == byte.class) {
return "[B";
} else if (clazz == char.class) {
return "[C";
} else if (clazz == double.class) {
return "[D";
} else if (clazz == float.class) {
return "[F";
} else if (clazz == int.class) {
return "[I";
} else if (clazz == long.class) {
return "[J";
} else if (clazz == short.class) {
return "[S";
} else {
return "[L" + clazz.getName() + ";";
}
}
private Class> toClass(ClassLoader classLoader, String name) {
try {
return classLoader != null ? Class.forName(name, false, classLoader) : Class.forName(name);
} catch (ClassNotFoundException e) {
throw new RuntimeException(
"Cannot determine array type for "
+ name
+ " using "
+ (classLoader == null ? "root classloader" : "base classloader"),
e);
}
}
@Override
public JsonElement serialize(Invocation src, Type typeOfSrc, JsonSerializationContext context) {
if (version == 1) {
log.warn("Serializing as deprecated version {}", version);
return serializeV1(src, typeOfSrc, context);
}
JsonObject obj = new JsonObject();
obj.addProperty("c", src.getClassName());
obj.addProperty("m", src.getMethodName());
JsonArray params = new JsonArray();
JsonArray args = new JsonArray();
int i = 0;
for (Class> parameterType : src.getParameterTypes()) {
params.add(nameForClass(parameterType));
Object arg = src.getArgs()[i];
if (arg == null) {
JsonObject jsonObject = new JsonObject();
jsonObject.add("t", null);
jsonObject.add("v", null);
args.add(jsonObject);
} else {
JsonObject jsonObject = new JsonObject();
jsonObject.addProperty("t", nameForClass(arg.getClass()));
jsonObject.add("v", context.serialize(arg));
args.add(jsonObject);
}
i++;
}
obj.add("p", params);
obj.add("a", args);
obj.add("x", context.serialize(src.getMdc()));
return obj;
}
JsonElement serializeV1(Invocation src, Type typeOfSrc, JsonSerializationContext context) {
JsonObject obj = new JsonObject();
obj.addProperty("c", src.getClassName());
obj.addProperty("m", src.getMethodName());
JsonArray params = new JsonArray();
int i = 0;
for (Class> parameterType : src.getParameterTypes()) {
JsonObject jsonObject = new JsonObject();
jsonObject.addProperty("t", nameForClass(parameterType));
jsonObject.add("v", context.serialize(src.getArgs()[i]));
params.add(jsonObject);
i++;
}
obj.add("p", params);
obj.add("x", context.serialize(src.getMdc()));
return obj;
}
@Override
public Invocation deserialize(
JsonElement json, Type typeOfT, JsonDeserializationContext context)
throws JsonParseException {
JsonObject jsonObject = json.getAsJsonObject();
String className = jsonObject.get("c").getAsString();
String methodName = jsonObject.get("m").getAsString();
JsonArray jsonParams = jsonObject.get("p").getAsJsonArray();
Class>[] params = new Class>[jsonParams.size()];
for (int i = 0; i < jsonParams.size(); i++) {
JsonElement param = jsonParams.get(i);
if (param.isJsonObject()) {
// For backwards compatibility
params[i] = classForName(param.getAsJsonObject().get("t").getAsString());
} else {
params[i] = classForName(param.getAsString());
}
}
JsonElement argsElement = jsonObject.get("a");
if (argsElement == null) {
// For backwards compatibility
argsElement = jsonObject.get("p");
}
JsonArray jsonArgs = argsElement.getAsJsonArray();
Object[] args = new Object[jsonArgs.size()];
for (int i = 0; i < jsonArgs.size(); i++) {
JsonElement arg = jsonArgs.get(i);
JsonElement argType = arg.getAsJsonObject().get("t");
if (argType != null) {
JsonElement argValue = arg.getAsJsonObject().get("v");
Class> argClass = classForName(argType.getAsString());
try {
args[i] = context.deserialize(argValue, argClass);
} catch (Exception e) {
throw new RuntimeException(
"Failed to deserialize arg [" + argValue + "] of type [" + argType + "]", e);
}
}
}
Map mdc = context.deserialize(jsonObject.get("x"), Map.class);
return new Invocation(className, methodName, params, args, mdc);
}
private Class> classForName(String name) {
var clazz = nameToClass.get(name);
if (clazz == null) {
throw new IllegalArgumentException("Cannot deserialize class - not found: " + name);
}
return clazz;
}
private String nameForClass(Class> clazz) {
var name = classToName.get(clazz);
if (name == null) {
throw new IllegalArgumentException(
"Cannot serialize class - not found: " + clazz.getName());
}
return name;
}
}
static final class LocalDateTimeTypeAdapter extends TypeAdapter {
@Override
public void write(JsonWriter out, LocalDateTime value) throws IOException {
out.value(value.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
}
@Override
public LocalDateTime read(JsonReader in) throws IOException {
return LocalDateTime.parse(in.nextString(), DateTimeFormatter.ISO_LOCAL_DATE_TIME);
}
}
static final class InstantTypeAdapter extends TypeAdapter {
@Override
public void write(JsonWriter out, Instant value) throws IOException {
out.value(DateTimeFormatter.ISO_INSTANT.format(value));
}
@Override
public Instant read(JsonReader in) throws IOException {
return DateTimeFormatter.ISO_INSTANT.parse(in.nextString(), Instant::from);
}
}
static final class DurationTypeAdapter extends TypeAdapter {
@Override
public void write(JsonWriter out, Duration value) throws IOException {
out.value(value.get(ChronoUnit.SECONDS));
}
@Override
public Duration read(JsonReader in) throws IOException {
return Duration.of(in.nextLong(), ChronoUnit.SECONDS);
}
}
static final class LocalDateTypeAdapter extends TypeAdapter {
@Override
public void write(JsonWriter out, LocalDate value) throws IOException {
out.value(DateTimeFormatter.ISO_LOCAL_DATE.format(value));
}
@Override
public LocalDate read(JsonReader in) throws IOException {
return DateTimeFormatter.ISO_LOCAL_DATE.parse(in.nextString(), LocalDate::from);
}
}
static final class MonthDayTypeAdapter extends TypeAdapter {
private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("d/M");
@Override
public void write(JsonWriter out, MonthDay value) throws IOException {
out.value(value.format(formatter));
}
@Override
public MonthDay read(JsonReader in) throws IOException {
return MonthDay.parse(in.nextString(), formatter);
}
}
static final class PeriodTypeAdapter extends TypeAdapter {
@Override
public void write(JsonWriter out, Period value) throws IOException {
out.value(value.toString());
}
@Override
public Period read(JsonReader in) throws IOException {
return Period.parse(in.nextString());
}
}
static final class YearTypeAdapter extends TypeAdapter {
@Override
public void write(JsonWriter out, Year value) throws IOException {
out.value(value.getValue());
}
@Override
public Year read(JsonReader in) throws IOException {
return Year.of(in.nextInt());
}
}
static final class YearMonthAdapter extends TypeAdapter {
@Override
public void write(JsonWriter out, YearMonth value) throws IOException {
out.value(value.toString());
}
@Override
public YearMonth read(JsonReader in) throws IOException {
return YearMonth.parse(in.nextString());
}
}
static final class UtcDateTypeAdapter extends TypeAdapter {
private final TimeZone UTC_TIME_ZONE = TimeZone.getTimeZone("UTC");
@Override
public void write(JsonWriter out, Date date) throws IOException {
if (date == null) {
out.nullValue();
} else {
String value = format(date, true, UTC_TIME_ZONE);
out.value(value);
}
}
@Override
public Date read(JsonReader in) throws IOException {
try {
if (in.peek() == JsonToken.NULL) {
in.nextNull();
return null;
}
String date = in.nextString();
// Instead of using iso8601Format.parse(value), we use Jackson's date parsing
// This is because Android doesn't support XXX because it is JDK 1.6
return parse(date, new ParsePosition(0));
} catch (ParseException e) {
throw new JsonParseException(e);
}
}
// Date parsing code from Jackson databind ISO8601Utils.java
// https://github.com/FasterXML/jackson-databind/blob/master/src/main/java/com/fasterxml/jackson/databind/util/ISO8601Utils.java
private static final String GMT_ID = "GMT";
/**
* Format date into yyyy-MM-ddThh:mm:ss[.sss][Z|[+-]hh:mm]
*
* @param date the date to format
* @param millis true to include millis precision otherwise false
* @param tz timezone to use for the formatting (GMT will produce 'Z')
* @return the date formatted as yyyy-MM-ddThh:mm:ss[.sss][Z|[+-]hh:mm]
*/
private static String format(Date date, boolean millis, TimeZone tz) {
Calendar calendar = new GregorianCalendar(tz, Locale.US);
calendar.setTime(date);
// estimate capacity of buffer as close as we can (yeah, that's pedantic ;)
int capacity = "yyyy-MM-ddThh:mm:ss".length();
capacity += millis ? ".sss".length() : 0;
capacity += tz.getRawOffset() == 0 ? "Z".length() : "+hh:mm".length();
StringBuilder formatted = new StringBuilder(capacity);
padInt(formatted, calendar.get(Calendar.YEAR), "yyyy".length());
formatted.append('-');
padInt(formatted, calendar.get(Calendar.MONTH) + 1, "MM".length());
formatted.append('-');
padInt(formatted, calendar.get(Calendar.DAY_OF_MONTH), "dd".length());
formatted.append('T');
padInt(formatted, calendar.get(Calendar.HOUR_OF_DAY), "hh".length());
formatted.append(':');
padInt(formatted, calendar.get(Calendar.MINUTE), "mm".length());
formatted.append(':');
padInt(formatted, calendar.get(Calendar.SECOND), "ss".length());
if (millis) {
formatted.append('.');
padInt(formatted, calendar.get(Calendar.MILLISECOND), "sss".length());
}
int offset = tz.getOffset(calendar.getTimeInMillis());
if (offset != 0) {
int hours = Math.abs((offset / (60 * 1000)) / 60);
int minutes = Math.abs((offset / (60 * 1000)) % 60);
formatted.append(offset < 0 ? '-' : '+');
padInt(formatted, hours, "hh".length());
formatted.append(':');
padInt(formatted, minutes, "mm".length());
} else {
formatted.append('Z');
}
return formatted.toString();
}
/**
* Zero pad a number to a specified length
*
* @param buffer buffer to use for padding
* @param value the integer value to pad if necessary.
* @param length the length of the string we should zero pad
*/
private static void padInt(StringBuilder buffer, int value, int length) {
String strValue = Integer.toString(value);
buffer.append("0".repeat(Math.max(0, length - strValue.length())));
buffer.append(strValue);
}
/**
* Parse a date from ISO-8601 formatted string. It expects a format
* [yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh:mm]]
*
* @param date ISO string to parse in the appropriate format.
* @param pos The position to start parsing from, updated to where parsing stopped.
* @return the parsed date
* @throws ParseException if the date is not in the appropriate format
*/
private static Date parse(String date, ParsePosition pos) throws ParseException {
Exception fail;
try {
int offset = pos.getIndex();
// extract year
int year = parseInt(date, offset, offset += 4);
if (checkOffset(date, offset, '-')) {
offset += 1;
}
// extract month
int month = parseInt(date, offset, offset += 2);
if (checkOffset(date, offset, '-')) {
offset += 1;
}
// extract day
int day = parseInt(date, offset, offset += 2);
// default time value
int hour = 0;
int minutes = 0;
int seconds = 0;
int milliseconds =
0; // always use 0 otherwise returned date will include millis of current time
if (checkOffset(date, offset, 'T')) {
// extract hours, minutes, seconds and milliseconds
hour = parseInt(date, offset += 1, offset += 2);
if (checkOffset(date, offset, ':')) {
offset += 1;
}
minutes = parseInt(date, offset, offset += 2);
if (checkOffset(date, offset, ':')) {
offset += 1;
}
// second and milliseconds can be optional
if (date.length() > offset) {
char c = date.charAt(offset);
if (c != 'Z' && c != '+' && c != '-') {
seconds = parseInt(date, offset, offset += 2);
// milliseconds can be optional in the format
if (checkOffset(date, offset, '.')) {
milliseconds = parseInt(date, offset += 1, offset += 3);
}
}
}
}
// extract timezone
String timezoneId;
if (date.length() <= offset) {
throw new IllegalArgumentException("No time zone indicator");
}
char timezoneIndicator = date.charAt(offset);
if (timezoneIndicator == '+' || timezoneIndicator == '-') {
String timezoneOffset = date.substring(offset);
timezoneId = GMT_ID + timezoneOffset;
offset += timezoneOffset.length();
} else if (timezoneIndicator == 'Z') {
timezoneId = GMT_ID;
offset += 1;
} else {
throw new IndexOutOfBoundsException("Invalid time zone indicator " + timezoneIndicator);
}
TimeZone timezone = TimeZone.getTimeZone(timezoneId);
if (!timezone.getID().equals(timezoneId)) {
throw new IndexOutOfBoundsException();
}
Calendar calendar = new GregorianCalendar(timezone);
calendar.setLenient(false);
calendar.set(Calendar.YEAR, year);
calendar.set(Calendar.MONTH, month - 1);
calendar.set(Calendar.DAY_OF_MONTH, day);
calendar.set(Calendar.HOUR_OF_DAY, hour);
calendar.set(Calendar.MINUTE, minutes);
calendar.set(Calendar.SECOND, seconds);
calendar.set(Calendar.MILLISECOND, milliseconds);
pos.setIndex(offset);
return calendar.getTime();
// If we get a ParseException it'll already have the right message/offset.
// Other exception types can convert here.
} catch (IndexOutOfBoundsException | IllegalArgumentException e) {
fail = e;
}
String input = (date == null) ? null : ("'" + date + "'");
throw new ParseException(
"Failed to parse date [" + input + "]: " + fail.getMessage(), pos.getIndex());
}
/**
* Check if the expected character exist at the given offset in the value.
*
* @param value the string to check at the specified offset
* @param offset the offset to look for the expected character
* @param expected the expected character
* @return true if the expected character exist at the given offset
*/
private static boolean checkOffset(String value, int offset, char expected) {
return (offset < value.length()) && (value.charAt(offset) == expected);
}
/**
* Parse an integer located between 2 given offsets in a string
*
* @param value the string to parse
* @param beginIndex the start index for the integer in the string
* @param endIndex the end index for the integer in the string
* @return the int
* @throws NumberFormatException if the value is not a number
*/
private static int parseInt(String value, int beginIndex, int endIndex)
throws NumberFormatException {
if (beginIndex < 0 || endIndex > value.length() || beginIndex > endIndex) {
throw new NumberFormatException(value);
}
// use same logic as in Integer.parseInt() but less generic we're not supporting negative
// values
int i = beginIndex;
int result = 0;
int digit;
if (i < endIndex) {
digit = Character.digit(value.charAt(i++), 10);
if (digit < 0) {
throw new NumberFormatException("Invalid number: " + value);
}
result = -digit;
}
while (i < endIndex) {
digit = Character.digit(value.charAt(i++), 10);
if (digit < 0) {
throw new NumberFormatException("Invalid number: " + value);
}
result *= 10;
result -= digit;
}
return -result;
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy