diff --git a/bot-api/build.gradle.kts b/bot-api/build.gradle.kts index 1a2995a..219585e 100644 --- a/bot-api/build.gradle.kts +++ b/bot-api/build.gradle.kts @@ -31,7 +31,7 @@ dependencies { requireCapability("${project.group}:${project.name}-config") } } - implementation(libs.jackson.annotations) + implementation(libs.gson) configApi(libs.annotations) configCompileOnly(libs.lombok) diff --git a/bot-api/src/main/java/eu/jonahbauer/chat/bot/api/Message.java b/bot-api/src/main/java/eu/jonahbauer/chat/bot/api/Message.java index fc0e284..0b0c34d 100644 --- a/bot-api/src/main/java/eu/jonahbauer/chat/bot/api/Message.java +++ b/bot-api/src/main/java/eu/jonahbauer/chat/bot/api/Message.java @@ -1,25 +1,12 @@ package eu.jonahbauer.chat.bot.api; -import com.fasterxml.jackson.annotation.JsonFormat; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonSubTypes; -import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.google.gson.annotations.SerializedName; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.time.LocalDateTime; -@JsonTypeInfo( - use = JsonTypeInfo.Id.NAME, - property = "type" -) -@JsonSubTypes({ - @JsonSubTypes.Type(value = Message.Ping.class, name = "ping"), - @JsonSubTypes.Type(value = Message.Pong.class, name = "pong"), - @JsonSubTypes.Type(value = Message.Ack.class, name = "ack"), - @JsonSubTypes.Type(value = Message.Post.class, name = "post") -}) public sealed interface Message { Ping PING = new Ping(); @@ -34,12 +21,11 @@ public sealed interface Message { @NotNull String name, @NotNull String message, @NotNull String channel, - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss") @NotNull LocalDateTime date, @Nullable Long delay, - @JsonProperty("user_id") + @SerializedName("user_id") @Nullable Long userId, - @JsonProperty("username") + @SerializedName("username") @Nullable String userName, @NotNull String color, int bottag diff --git a/bot-api/src/main/java/module-info.java b/bot-api/src/main/java/module-info.java index c04c146..7209c31 100644 --- a/bot-api/src/main/java/module-info.java +++ b/bot-api/src/main/java/module-info.java @@ -7,7 +7,7 @@ module eu.jonahbauer.chat.bot.api { requires transitive eu.jonahbauer.chat.bot.config; requires static transitive org.jetbrains.annotations; - requires static com.fasterxml.jackson.annotation; + requires static com.google.gson; requires static lombok; uses ChatBot; diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d7c731d..af626f5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] annotations = "24.1.0" +gson = "2.10.1" hikari = "5.1.0" -jackson = "2.16.1" junit = "5.10.2" logback = "1.5.3" lombok = "1.18.30" @@ -10,16 +10,11 @@ sqlite = "3.44.1.0" [libraries] annotations = { module = "org.jetbrains:annotations", version.ref = "annotations" } +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } hikari = { module = "com.zaxxer:HikariCP", version.ref = "hikari" } -jackson-annotations = { module = "com.fasterxml.jackson.core:jackson-annotations", version.ref = "jackson"} -jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson"} -jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson"} logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" } lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" } slf4j = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } sqlite = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite"} - -[bundles] -jackson = ["jackson-databind", "jackson-annotations", "jackson-datatype-jsr310"] \ No newline at end of file diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 9f81133..55ca829 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -11,7 +11,7 @@ val bots = project(":bots").subprojects dependencies { implementation(project(":bot-api")) implementation(project(":management")) - implementation(libs.bundles.jackson) + implementation(libs.gson) implementation(libs.slf4j) runtimeOnly(libs.logback) diff --git a/server/src/main/java/eu/jonahbauer/chat/server/Config.java b/server/src/main/java/eu/jonahbauer/chat/server/Config.java index bbaaa04..f433afe 100644 --- a/server/src/main/java/eu/jonahbauer/chat/server/Config.java +++ b/server/src/main/java/eu/jonahbauer/chat/server/Config.java @@ -1,13 +1,13 @@ package eu.jonahbauer.chat.server; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.google.gson.GsonBuilder; import eu.jonahbauer.chat.bot.config.BotConfig; -import eu.jonahbauer.chat.server.bot.BotConfigDeserializer; +import eu.jonahbauer.chat.server.json.BotConfigDeserializer; import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; import java.io.IOException; +import java.io.InputStreamReader; import java.net.URI; import java.net.URL; import java.util.Map; @@ -18,11 +18,10 @@ import java.util.Set; public record Config( @NotNull Account account, @NotNull Set<@NotNull String> channels, - @JsonDeserialize(contentUsing = BotConfigDeserializer.class) @NotNull Map<@NotNull String, @NotNull BotConfig> bots ) { private static final String CONFIGURATION_FILE_ENV = "CHAT_BOT_CONFIG"; - private static final String CONFIGURATION_FILE_PROPERTY = "chatbot.configurationFile"; + private static final String CONFIGURATION_FILE_PROPERTY = "chatbot.configuration"; private static final String DEFAULT_CONFIGURATION_FILE = "file:./config.json"; public static @NotNull Config load() throws IOException { @@ -40,10 +39,14 @@ public record Config( } public static @NotNull Config read(@NotNull URL url) throws IOException { - var mapper = new ObjectMapper(); - return mapper.readValue(url, Config.class); log.info("Loading configuration from " + url); + var gson = new GsonBuilder() + .registerTypeAdapter(BotConfig.class, new BotConfigDeserializer()) + .create(); + try (var in = url.openStream(); var reader = new InputStreamReader(in)) { + return gson.fromJson(reader, Config.class); + } } public Config { diff --git a/server/src/main/java/eu/jonahbauer/chat/server/bot/BotConfigDeserializer.java b/server/src/main/java/eu/jonahbauer/chat/server/bot/BotConfigDeserializer.java deleted file mode 100644 index 9b6bda7..0000000 --- a/server/src/main/java/eu/jonahbauer/chat/server/bot/BotConfigDeserializer.java +++ /dev/null @@ -1,61 +0,0 @@ -package eu.jonahbauer.chat.server.bot; - -import com.fasterxml.jackson.core.JsonParseException; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonToken; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.deser.std.StdDeserializer; -import eu.jonahbauer.chat.bot.config.BotConfig; -import eu.jonahbauer.chat.bot.config.BotConfigurationException; - -import java.io.IOException; -import java.util.ArrayList; - -public class BotConfigDeserializer extends StdDeserializer { - - protected BotConfigDeserializer() { - super(BotConfig.class); - } - - @Override - public BotConfig deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { - if (p.currentToken() == JsonToken.START_OBJECT) { - p.nextToken(); - } - - var builder = BotConfig.builder(); - while (p.currentToken() == JsonToken.FIELD_NAME) { - var key = p.currentName(); - - switch (p.nextToken()) { - case VALUE_STRING -> builder.value(key, p.getValueAsString()); - case VALUE_FALSE -> builder.value(key, false); - case VALUE_TRUE -> builder.value(key, true); - case VALUE_NULL -> {} - case VALUE_NUMBER_FLOAT -> builder.value(key, p.getDoubleValue()); - case VALUE_NUMBER_INT -> builder.value(key, p.getLongValue()); - case START_ARRAY -> { - var list = new ArrayList(); - while (p.nextToken() != JsonToken.END_ARRAY) { - if (p.currentToken() == JsonToken.VALUE_STRING) { - list.add(p.getValueAsString()); - } else { - throw new BotConfigurationException("Unsupported property type in array."); - } - } - builder.value(key, list); - } - case START_OBJECT -> throw new BotConfigurationException("Unsupported property type: object"); - default -> throw new BotConfigurationException("Unsupported property property type."); - } - - p.nextToken(); - } - - if (p.currentToken() == JsonToken.END_OBJECT) { - return builder.build(); - } - - throw new JsonParseException(p, "Unexpected token: " + p.currentToken(), p.currentLocation()); - } -} diff --git a/server/src/main/java/eu/jonahbauer/chat/server/json/BooleanTypeAdapter.java b/server/src/main/java/eu/jonahbauer/chat/server/json/BooleanTypeAdapter.java new file mode 100644 index 0000000..16ee106 --- /dev/null +++ b/server/src/main/java/eu/jonahbauer/chat/server/json/BooleanTypeAdapter.java @@ -0,0 +1,30 @@ +package eu.jonahbauer.chat.server.json; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; + +public class BooleanTypeAdapter extends TypeAdapter { + @Override + public void write(@NotNull JsonWriter out, @Nullable Boolean value) throws IOException { + if (value == null) { + out.nullValue(); + } else { + out.value(value ? 1 : 0); + } + } + + @Override + public Boolean read(@NotNull JsonReader in) throws IOException { + return switch (in.peek()) { + case NUMBER -> in.nextInt() != 0; + case BOOLEAN -> in.nextBoolean(); + case NULL -> null; + default -> throw new IllegalStateException("Expected a number but was " + in.peek()); + }; + } +} diff --git a/server/src/main/java/eu/jonahbauer/chat/server/json/BotConfigDeserializer.java b/server/src/main/java/eu/jonahbauer/chat/server/json/BotConfigDeserializer.java new file mode 100644 index 0000000..fe452be --- /dev/null +++ b/server/src/main/java/eu/jonahbauer/chat/server/json/BotConfigDeserializer.java @@ -0,0 +1,55 @@ +package eu.jonahbauer.chat.server.json; + +import com.google.gson.*; +import eu.jonahbauer.chat.bot.config.BotConfig; +import eu.jonahbauer.chat.bot.config.BotConfigurationException; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Type; + +public class BotConfigDeserializer implements JsonDeserializer { + + @Override + public @Nullable BotConfig deserialize(@NotNull JsonElement json, @NotNull Type typeOfT, @NotNull JsonDeserializationContext context) throws JsonParseException { + if (json.isJsonNull()) return null; + if (!json.isJsonObject()) throw new BotConfigurationException("Expected an object"); + var obj = json.getAsJsonObject(); + + var builder = BotConfig.builder(); + + obj.asMap().forEach((key, value) -> { + if (value instanceof JsonPrimitive primitive) { + if (primitive.isBoolean()) { + builder.value(key, primitive.getAsBoolean()); + } else if (primitive.isString()) { + builder.value(key, primitive.getAsString()); + } else if (primitive.isNumber()) { + switch (primitive.getAsNumber()) { + case Long l -> builder.value(key, l); + case Double d -> builder.value(key, d); + case Number n -> throw new BotConfigurationException("Invalid number: " + n); + } + } else { + throw new AssertionError(); + } + } else if (value.isJsonArray()) { + var list = value.getAsJsonArray().asList().stream() + .map(e -> { + if (e instanceof JsonPrimitive primitive && primitive.isString()) { + return primitive.getAsString(); + } else { + throw new BotConfigurationException("Unsupported property type in array."); + } + }) + .toList(); + builder.value(key, list); + } else { + throw new BotConfigurationException("Unsupported property type."); + } + }); + + return builder.build(); + } + +} diff --git a/server/src/main/java/eu/jonahbauer/chat/server/json/LocalDateTimeTypeAdapter.java b/server/src/main/java/eu/jonahbauer/chat/server/json/LocalDateTimeTypeAdapter.java new file mode 100644 index 0000000..17310b0 --- /dev/null +++ b/server/src/main/java/eu/jonahbauer/chat/server/json/LocalDateTimeTypeAdapter.java @@ -0,0 +1,33 @@ +package eu.jonahbauer.chat.server.json; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class LocalDateTimeTypeAdapter extends TypeAdapter { + private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Override + public void write(@NotNull JsonWriter out, @Nullable LocalDateTime value) throws IOException { + if (value == null) { + out.nullValue(); + } else { + out.value(formatter.format(value)); + } + } + + @Override + public LocalDateTime read(JsonReader in) throws IOException { + return switch (in.peek()) { + case NULL -> null; + case STRING -> formatter.parse(in.nextString(), LocalDateTime::from); + default -> throw new IllegalStateException("Expected a string but was " + in.peek()); + }; + } +} diff --git a/server/src/main/java/eu/jonahbauer/chat/server/json/RuntimeTypeAdapterFactory.java b/server/src/main/java/eu/jonahbauer/chat/server/json/RuntimeTypeAdapterFactory.java new file mode 100644 index 0000000..f9c91ef --- /dev/null +++ b/server/src/main/java/eu/jonahbauer/chat/server/json/RuntimeTypeAdapterFactory.java @@ -0,0 +1,327 @@ +/* + * Copyright (C) 2011 Google Inc. + * + * Licensed 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 eu.jonahbauer.chat.server.json; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Adapts values whose runtime type may differ from their declaration type. This is necessary when a + * field's type is not the same type that GSON should create when deserializing that field. For + * example, consider these types: + * + *
{@code
+ * abstract class Shape {
+ *   int x;
+ *   int y;
+ * }
+ * class Circle extends Shape {
+ *   int radius;
+ * }
+ * class Rectangle extends Shape {
+ *   int width;
+ *   int height;
+ * }
+ * class Diamond extends Shape {
+ *   int width;
+ *   int height;
+ * }
+ * class Drawing {
+ *   Shape bottomShape;
+ *   Shape topShape;
+ * }
+ * }
+ * + *

Without additional type information, the serialized JSON is ambiguous. Is the bottom shape in + * this drawing a rectangle or a diamond? + * + *

{@code
+ * {
+ *   "bottomShape": {
+ *     "width": 10,
+ *     "height": 5,
+ *     "x": 0,
+ *     "y": 0
+ *   },
+ *   "topShape": {
+ *     "radius": 2,
+ *     "x": 4,
+ *     "y": 1
+ *   }
+ * }
+ * }
+ * + * This class addresses this problem by adding type information to the serialized JSON and honoring + * that type information when the JSON is deserialized: + * + *
{@code
+ * {
+ *   "bottomShape": {
+ *     "type": "Diamond",
+ *     "width": 10,
+ *     "height": 5,
+ *     "x": 0,
+ *     "y": 0
+ *   },
+ *   "topShape": {
+ *     "type": "Circle",
+ *     "radius": 2,
+ *     "x": 4,
+ *     "y": 1
+ *   }
+ * }
+ * }
+ * + * Both the type field name ({@code "type"}) and the type labels ({@code "Rectangle"}) are + * configurable. + * + *

Registering Types

+ * + * Create a {@code RuntimeTypeAdapterFactory} by passing the base type and type field name to the + * {@link #of} factory method. If you don't supply an explicit type field name, {@code "type"} will + * be used. + * + *
{@code
+ * RuntimeTypeAdapterFactory shapeAdapterFactory
+ *     = RuntimeTypeAdapterFactory.of(Shape.class, "type");
+ * }
+ * + * Next register all of your subtypes. Every subtype must be explicitly registered. This protects + * your application from injection attacks. If you don't supply an explicit type label, the type's + * simple name will be used. + * + *
{@code
+ * shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle");
+ * shapeAdapterFactory.registerSubtype(Circle.class, "Circle");
+ * shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond");
+ * }
+ * + * Finally, register the type adapter factory in your application's GSON builder: + * + *
{@code
+ * Gson gson = new GsonBuilder()
+ *     .registerTypeAdapterFactory(shapeAdapterFactory)
+ *     .create();
+ * }
+ * + * Like {@code GsonBuilder}, this API supports chaining: + * + *
{@code
+ * RuntimeTypeAdapterFactory shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class)
+ *     .registerSubtype(Rectangle.class)
+ *     .registerSubtype(Circle.class)
+ *     .registerSubtype(Diamond.class);
+ * }
+ * + *

Serialization and deserialization

+ * + * In order to serialize and deserialize a polymorphic object, you must specify the base type + * explicitly. + * + *
{@code
+ * Diamond diamond = new Diamond();
+ * String json = gson.toJson(diamond, Shape.class);
+ * }
+ * + * And then: + * + *
{@code
+ * Shape shape = gson.fromJson(json, Shape.class);
+ * }
+ */ +public final class RuntimeTypeAdapterFactory implements TypeAdapterFactory { + private final Class baseType; + private final String typeFieldName; + private final Map> labelToSubtype = new LinkedHashMap<>(); + private final Map, String> subtypeToLabel = new LinkedHashMap<>(); + private final boolean maintainType; + private boolean recognizeSubtypes; + + private RuntimeTypeAdapterFactory(Class baseType, String typeFieldName, boolean maintainType) { + if (typeFieldName == null || baseType == null) { + throw new NullPointerException(); + } + this.baseType = baseType; + this.typeFieldName = typeFieldName; + this.maintainType = maintainType; + } + + /** + * Creates a new runtime type adapter using for {@code baseType} using {@code typeFieldName} as + * the type field name. Type field names are case sensitive. + * + * @param maintainType true if the type field should be included in deserialized objects + */ + public static RuntimeTypeAdapterFactory of( + Class baseType, String typeFieldName, boolean maintainType) { + return new RuntimeTypeAdapterFactory<>(baseType, typeFieldName, maintainType); + } + + /** + * Creates a new runtime type adapter using for {@code baseType} using {@code typeFieldName} as + * the type field name. Type field names are case sensitive. + */ + public static RuntimeTypeAdapterFactory of(Class baseType, String typeFieldName) { + return new RuntimeTypeAdapterFactory<>(baseType, typeFieldName, false); + } + + /** + * Creates a new runtime type adapter for {@code baseType} using {@code "type"} as the type field + * name. + */ + public static RuntimeTypeAdapterFactory of(Class baseType) { + return new RuntimeTypeAdapterFactory<>(baseType, "type", false); + } + + /** + * Ensures that this factory will handle not just the given {@code baseType}, but any subtype of + * that type. + */ + public RuntimeTypeAdapterFactory recognizeSubtypes() { + this.recognizeSubtypes = true; + return this; + } + + /** + * Registers {@code type} identified by {@code label}. Labels are case sensitive. + * + * @throws IllegalArgumentException if either {@code type} or {@code label} have already been + * registered on this type adapter. + */ + public RuntimeTypeAdapterFactory registerSubtype(Class type, String label) { + if (type == null || label == null) { + throw new NullPointerException(); + } + if (subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label)) { + throw new IllegalArgumentException("types and labels must be unique"); + } + labelToSubtype.put(label, type); + subtypeToLabel.put(type, label); + return this; + } + + /** + * Registers {@code type} identified by its {@link Class#getSimpleName simple name}. Labels are + * case sensitive. + * + * @throws IllegalArgumentException if either {@code type} or its simple name have already been + * registered on this type adapter. + */ + public RuntimeTypeAdapterFactory registerSubtype(Class type) { + return registerSubtype(type, type.getSimpleName()); + } + + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + if (type == null) { + return null; + } + Class rawType = type.getRawType(); + boolean handle = + recognizeSubtypes ? baseType.isAssignableFrom(rawType) : baseType.equals(rawType); + if (!handle) { + return null; + } + + final TypeAdapter jsonElementAdapter = gson.getAdapter(JsonElement.class); + final Map> labelToDelegate = new LinkedHashMap<>(); + final Map, TypeAdapter> subtypeToDelegate = new LinkedHashMap<>(); + for (Map.Entry> entry : labelToSubtype.entrySet()) { + TypeAdapter delegate = gson.getDelegateAdapter(this, TypeToken.get(entry.getValue())); + labelToDelegate.put(entry.getKey(), delegate); + subtypeToDelegate.put(entry.getValue(), delegate); + } + + return new TypeAdapter() { + @Override + public R read(JsonReader in) throws IOException { + JsonElement jsonElement = jsonElementAdapter.read(in); + JsonElement labelJsonElement; + if (maintainType) { + labelJsonElement = jsonElement.getAsJsonObject().get(typeFieldName); + } else { + labelJsonElement = jsonElement.getAsJsonObject().remove(typeFieldName); + } + + if (labelJsonElement == null) { + throw new JsonParseException( + "cannot deserialize " + + baseType + + " because it does not define a field named " + + typeFieldName); + } + String label = labelJsonElement.getAsString(); + @SuppressWarnings("unchecked") // registration requires that subtype extends T + TypeAdapter delegate = (TypeAdapter) labelToDelegate.get(label); + if (delegate == null) { + throw new JsonParseException( + "cannot deserialize " + + baseType + + " subtype named " + + label + + "; did you forget to register a subtype?"); + } + return delegate.fromJsonTree(jsonElement); + } + + @Override + public void write(JsonWriter out, R value) throws IOException { + Class srcType = value.getClass(); + String label = subtypeToLabel.get(srcType); + @SuppressWarnings("unchecked") // registration requires that subtype extends T + TypeAdapter delegate = (TypeAdapter) subtypeToDelegate.get(srcType); + if (delegate == null) { + throw new JsonParseException( + "cannot serialize " + srcType.getName() + "; did you forget to register a subtype?"); + } + JsonObject jsonObject = delegate.toJsonTree(value).getAsJsonObject(); + + if (maintainType) { + jsonElementAdapter.write(out, jsonObject); + return; + } + + JsonObject clone = new JsonObject(); + + if (jsonObject.has(typeFieldName)) { + throw new JsonParseException( + "cannot serialize " + + srcType.getName() + + " because it already defines a field named " + + typeFieldName); + } + clone.add(typeFieldName, new JsonPrimitive(label)); + + for (Map.Entry e : jsonObject.entrySet()) { + clone.add(e.getKey(), e.getValue()); + } + jsonElementAdapter.write(out, clone); + } + }.nullSafe(); + } +} diff --git a/server/src/main/java/eu/jonahbauer/chat/server/socket/OutgoingMessage.java b/server/src/main/java/eu/jonahbauer/chat/server/socket/OutgoingMessage.java index 04c134f..04d2fcf 100644 --- a/server/src/main/java/eu/jonahbauer/chat/server/socket/OutgoingMessage.java +++ b/server/src/main/java/eu/jonahbauer/chat/server/socket/OutgoingMessage.java @@ -1,6 +1,5 @@ package eu.jonahbauer.chat.server.socket; -import com.fasterxml.jackson.annotation.JsonFormat; import org.jetbrains.annotations.NotNull; import java.util.Objects; @@ -10,8 +9,8 @@ public record OutgoingMessage( @NotNull String message, @NotNull String channel, long delay, - @JsonFormat(shape = JsonFormat.Shape.NUMBER) boolean publicid, - @JsonFormat(shape = JsonFormat.Shape.NUMBER) boolean bottag + boolean publicid, + boolean bottag ) { public OutgoingMessage { Objects.requireNonNull(channel, "channel"); diff --git a/server/src/main/java/eu/jonahbauer/chat/server/socket/SocketSupervisor.java b/server/src/main/java/eu/jonahbauer/chat/server/socket/SocketSupervisor.java index 6545eb6..965b7e6 100644 --- a/server/src/main/java/eu/jonahbauer/chat/server/socket/SocketSupervisor.java +++ b/server/src/main/java/eu/jonahbauer/chat/server/socket/SocketSupervisor.java @@ -1,12 +1,13 @@ package eu.jonahbauer.chat.server.socket; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.google.gson.*; import eu.jonahbauer.chat.bot.api.Chat; import eu.jonahbauer.chat.bot.api.Message; import eu.jonahbauer.chat.server.Config; import eu.jonahbauer.chat.server.bot.ChatBotSupervisor; +import eu.jonahbauer.chat.server.json.BooleanTypeAdapter; +import eu.jonahbauer.chat.server.json.LocalDateTimeTypeAdapter; +import eu.jonahbauer.chat.server.json.RuntimeTypeAdapterFactory; import eu.jonahbauer.chat.server.management.SocketState; import eu.jonahbauer.chat.server.management.impl.SocketManager; import eu.jonahbauer.chat.server.management.impl.SocketSupport; @@ -17,7 +18,6 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import javax.management.JMException; -import java.io.IOException; import java.net.CookieManager; import java.net.URI; import java.net.http.HttpClient; @@ -27,6 +27,7 @@ import java.net.http.HttpResponse; import java.net.http.WebSocket; import java.nio.ByteBuffer; import java.time.Instant; +import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.locks.ReentrantLock; @@ -41,7 +42,17 @@ public final class SocketSupervisor implements Chat, AutoCloseable { private static final int PING_INTERVAL = 30; private static final ScheduledExecutorService SCHEDULER = Executors.newScheduledThreadPool(1, Thread.ofVirtual().name("SocketSupervisor").factory()); - private static final ObjectMapper MAPPER = new ObjectMapper().registerModule(new JavaTimeModule()); + private static final Gson GSON = new GsonBuilder() + .registerTypeAdapterFactory(RuntimeTypeAdapterFactory.of(Message.class) + .registerSubtype(Message.Ping.class, "ping") + .registerSubtype(Message.Post.class, "post") + .registerSubtype(Message.Ack.class, "ack") + .registerSubtype(Message.Pong.class, "pong") + ) + .registerTypeAdapter(boolean.class, new BooleanTypeAdapter()) + .registerTypeAdapter(Boolean.class, new BooleanTypeAdapter()) + .registerTypeAdapter(LocalDateTime.class, new LocalDateTimeTypeAdapter()) + .create(); private volatile @Nullable Config.Account account; private volatile @Nullable Credentials credentials; @@ -74,10 +85,10 @@ public final class SocketSupervisor implements Chat, AutoCloseable { } else { var out = new OutgoingMessage(name, message, channel, socket.delay, publicId, bottag); try { - socket.send(MAPPER.writeValueAsString(out)); + socket.send(GSON.toJson(out, OutgoingMessage.class)); log.info("Sending message: {}", out); return true; - } catch (JsonProcessingException e) { + } catch (Exception e) { log.error("Could not serialize message: {}", out, e); return false; } @@ -445,8 +456,13 @@ public final class SocketSupervisor implements Chat, AutoCloseable { private void onMessage(@NotNull List<@NotNull CharSequence> parts) { var text = String.join("", parts); + Message message; try { - var message = MAPPER.readValue(text, Message.class); + message = GSON.fromJson(text, Message.class); + } catch (Exception e) { + log.warn("Could not parse as message: {}", text, e); + return; + } if (message instanceof Message.Post) { log.info("Received message: {}", message); @@ -454,12 +470,9 @@ public final class SocketSupervisor implements Chat, AutoCloseable { log.debug("Received message: {}", message); } - if (message instanceof Message.Post post) { - delay = post.id() + 1; - SocketSupervisor.this.chatBotSupervisor.get().onMessage(post); - } - } catch (JsonProcessingException e) { - log.warn("Could not parse as message: {}", text, e); + if (message instanceof Message.Post post) { + delay = post.id() + 1; + SocketSupervisor.this.chatBotSupervisor.get().onMessage(post); } } @@ -531,12 +544,8 @@ public final class SocketSupervisor implements Chat, AutoCloseable { } private void ping() { - try { - socket.sendText(MAPPER.writeValueAsString(Message.PING), true); - log.debug("Sending message: {}", Message.PING); - } catch (IOException ex) { - log.error("Failed to send ping", ex); - } + socket.sendText(GSON.toJson(Message.PING, Message.class), true); + log.debug("Sending message: {}", Message.PING); } @Override diff --git a/server/src/main/java/module-info.java b/server/src/main/java/module-info.java index c992f54..854e03f 100644 --- a/server/src/main/java/module-info.java +++ b/server/src/main/java/module-info.java @@ -1,17 +1,15 @@ module eu.jonahbauer.chat.server { exports eu.jonahbauer.chat.server; - opens eu.jonahbauer.chat.server.bot to com.fasterxml.jackson.databind; - opens eu.jonahbauer.chat.server.socket to com.fasterxml.jackson.databind; + opens eu.jonahbauer.chat.server.bot to com.google.gson; + opens eu.jonahbauer.chat.server.socket to com.google.gson; - requires com.fasterxml.jackson.core; - requires com.fasterxml.jackson.databind; - requires com.fasterxml.jackson.datatype.jsr310; + requires com.google.gson; requires eu.jonahbauer.chat.bot.api; requires eu.jonahbauer.chat.server.management; requires java.management; requires java.net.http; - requires org.jetbrains.annotations; requires org.slf4j; + requires static transitive org.jetbrains.annotations; requires static lombok; } \ No newline at end of file