diff --git a/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java b/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java index db55c5791..20f424bd9 100644 --- a/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java +++ b/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java @@ -6,6 +6,7 @@ import com.slack.api.SlackConfig; import com.slack.api.audit.response.LogsResponse; import com.slack.api.model.Attachment; +import com.slack.api.model.File; import com.slack.api.model.admin.AppWorkflow; import com.slack.api.model.block.ContextBlockElement; import com.slack.api.model.block.LayoutBlock; @@ -31,6 +32,7 @@ public static Gson createSnakeCase() { return new GsonBuilder() .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) .registerTypeAdapter(Instant.class, new JavaTimeInstantFactory()) + .registerTypeAdapter(File.class, new GsonFileFactory()) .registerTypeAdapter(LayoutBlock.class, new GsonLayoutBlockFactory()) .registerTypeAdapter(TextObject.class, new GsonTextObjectFactory()) .registerTypeAdapter(ContextBlockElement.class, new GsonContextBlockElementFactory()) @@ -54,6 +56,7 @@ public static Gson createSnakeCase(SlackConfig config) { GsonBuilder gsonBuilder = new GsonBuilder() .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) .registerTypeAdapter(Instant.class, new JavaTimeInstantFactory(failOnUnknownProps)) + .registerTypeAdapter(File.class, new GsonFileFactory(failOnUnknownProps)) .registerTypeAdapter(LayoutBlock.class, new GsonLayoutBlockFactory(failOnUnknownProps)) .registerTypeAdapter(TextObject.class, new GsonTextObjectFactory(failOnUnknownProps)) .registerTypeAdapter(ContextBlockElement.class, new GsonContextBlockElementFactory(failOnUnknownProps)) @@ -82,6 +85,7 @@ public static Gson createCamelCase(SlackConfig config) { boolean failOnUnknownProps = config.isFailOnUnknownProperties(); GsonBuilder gsonBuilder = new GsonBuilder() .registerTypeAdapter(Instant.class, new JavaTimeInstantFactory(failOnUnknownProps)) + .registerTypeAdapter(File.class, new GsonFileFactory(failOnUnknownProps)) .registerTypeAdapter(LayoutBlock.class, new GsonLayoutBlockFactory(failOnUnknownProps)) .registerTypeAdapter(TextObject.class, new GsonTextObjectFactory(failOnUnknownProps)) .registerTypeAdapter(ContextBlockElement.class, new GsonContextBlockElementFactory(failOnUnknownProps)) diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/GsonFileFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/GsonFileFactory.java new file mode 100644 index 000000000..732fe167c --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/util/json/GsonFileFactory.java @@ -0,0 +1,67 @@ +package com.slack.api.util.json; + +import com.google.gson.*; +import com.slack.api.model.File; + +import java.lang.reflect.Type; + +public class GsonFileFactory implements JsonDeserializer, JsonSerializer { + + // This is just a workaround to customize Gson library behavior + // You don't need to edit this class at all + static class NormalizedFile extends File { + } + + private boolean failOnUnknownProperties; + + public GsonFileFactory() { + this(false); + } + + public GsonFileFactory(boolean failOnUnknownProperties) { + this.failOnUnknownProperties = failOnUnknownProperties; + } + + @Override + public File deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + final JsonObject jsonObject = json.getAsJsonObject(); + // Remove unusual data structure form Slack API server + // See https://github.com/slackapi/java-slack-sdk/issues/1426 for more details + if (jsonObject.has("groups") && !jsonObject.get("groups").isJsonArray()) { + // As the starting point in Jan 2025, we just ignore this property, + // but we may want to assign to a different field if it's necessary for some use cases + jsonObject.remove("groups"); + } + if (jsonObject.has("shares")) { + JsonObject shares = jsonObject.get("shares").getAsJsonObject(); + if (shares.has("public")) { + adjustSharesObjects(shares.get("public").getAsJsonObject()); + } + if (shares.has("private")) { + adjustSharesObjects(shares.get("private").getAsJsonObject()); + } + } + // To prevent StackOverflowError here, run the deserialize method for File's subclass. + // If we want to attach the above unusual data, you can add it to File class + return context.deserialize(jsonObject, NormalizedFile.class); + } + + private void adjustSharesObjects(JsonObject shares) { + for (String channelId : shares.keySet()) { + for (JsonElement elem : shares.get(channelId).getAsJsonArray()) { + JsonObject e = elem.getAsJsonObject(); + if (e.has("reply_users") && !e.get("reply_users").isJsonArray()) { + // As the starting point in Jan 2025, we just ignore this property, + // but we may want to assign to a different field if it's necessary for some use cases + e.remove("reply_users"); + } + } + } + } + + @Override + public JsonElement serialize(File src, Type typeOfSrc, JsonSerializationContext context) { + return context.serialize(src); + } +} diff --git a/slack-api-model/src/test/java/test_locally/api/model/FileTest.java b/slack-api-model/src/test/java/test_locally/api/model/FileTest.java new file mode 100644 index 000000000..7267409b8 --- /dev/null +++ b/slack-api-model/src/test/java/test_locally/api/model/FileTest.java @@ -0,0 +1,91 @@ +package test_locally.api.model; + +import com.slack.api.model.File; +import org.junit.Test; +import test_locally.unit.GsonFactory; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +public class FileTest { + + String ISSUE_1426_JSON = "{\n" + + " \"id\": \"F08AF7HUWQL\",\n" + + " \"created\": 1737949586,\n" + + " \"timestamp\": 1737949586,\n" + + " \"name\": \"sample.txt\",\n" + + " \"title\": \"sample.txt\",\n" + + " \"mimetype\": \"text/plain\",\n" + + " \"filetype\": \"text\",\n" + + " \"pretty_type\": \"Plain Text\",\n" + + " \"user\": \"U8P5K48E6\",\n" + + " \"user_team\": \"T03E94MJU\",\n" + + " \"editable\": true,\n" + + " \"size\": 57,\n" + + " \"mode\": \"snippet\",\n" + + " \"is_external\": false,\n" + + " \"external_type\": \"\",\n" + + " \"is_public\": true,\n" + + " \"public_url_shared\": false,\n" + + " \"display_as_bot\": false,\n" + + " \"username\": \"\",\n" + + " \"url_private\": \"https://files.slack.com/files-pri/T03E94MJU-F08AF7HUWQL/sample.txt\",\n" + + " \"url_private_download\": \"https://files.slack.com/files-pri/T03E94MJU-F08AF7HUWQL/download/sample.txt\",\n" + + " \"permalink\": \"https://seratch.slack.com/files/U8P5K48E6/F08AF7HUWQL/sample.txt\",\n" + + " \"permalink_public\": \"https://slack-files.com/T03E94MJU-F08AF7HUWQL-9d27bd3319\",\n" + + " \"edit_link\": \"https://seratch.slack.com/files/U8P5K48E6/F08AF7HUWQL/sample.txt/edit\",\n" + + " \"preview\": \"Hello, World!!!!!\\n\\nThis is a sample text file.\\n\\n日本語\",\n" + + " \"preview_highlight\": \"\\u003cdiv class\\u003d\\\"CodeMirror cm-s-default CodeMirrorServer\\\"\\u003e\\n\\u003cdiv class\\u003d\\\"CodeMirror-code\\\"\\u003e\\n\\u003cdiv\\u003e\\u003cpre\\u003eHello, World!!!!!\\u003c/pre\\u003e\\u003c/div\\u003e\\n\\u003cdiv\\u003e\\u003cpre\\u003e\\u003c/pre\\u003e\\u003c/div\\u003e\\n\\u003cdiv\\u003e\\u003cpre\\u003eThis is a sample text file.\\u003c/pre\\u003e\\u003c/div\\u003e\\n\\u003cdiv\\u003e\\u003cpre\\u003e\\u003c/pre\\u003e\\u003c/div\\u003e\\n\\u003cdiv\\u003e\\u003cpre\\u003e日本語\\u003c/pre\\u003e\\u003c/div\\u003e\\n\\u003c/div\\u003e\\n\\u003c/div\\u003e\\n\",\n" + + " \"lines\": 5,\n" + + " \"lines_more\": 0,\n" + + " \"preview_is_truncated\": false,\n" + + " \"favorites\": [],\n" + + " \"is_starred\": false,\n" + + " \"shares\": {\n" + + " \"public\": {\n" + + " \"C03E94MKU\": [\n" + + " {\n" + + " \"reply_users\": {},\n" + // unusual data structure here + " \"reply_users_count\": 0,\n" + + " \"reply_count\": 0,\n" + + " \"ts\": \"1737949587.842889\",\n" + + " \"channel_name\": \"random\",\n" + + " \"team_id\": \"T03E94MJU\",\n" + + " \"share_user_id\": \"U8P5K48E6\",\n" + + " \"source\": \"UNKNOWN\"\n" + + " }\n" + + " ],\n" + + " \"C03E94MKS\": [\n" + + " {\n" + + " \"reply_users\": [],\n" + + " \"reply_users_count\": 0,\n" + + " \"reply_count\": 0,\n" + + " \"ts\": \"1737949587.678069\",\n" + + " \"channel_name\": \"general\",\n" + + " \"team_id\": \"T03E94MJU\",\n" + + " \"share_user_id\": \"U8P5K48E6\",\n" + + " \"source\": \"UNKNOWN\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " },\n" + + " \"channels\": [\n" + + " \"C03E94MKU\",\n" + + " \"C03E94MKS\"\n" + + " ],\n" + + " \"groups\": {},\n" + // unusual data structure here + " \"ims\": [],\n" + + " \"has_more_shares\": false,\n" + + " \"has_rich_preview\": false,\n" + + " \"file_access\": \"visible\",\n" + + " \"comments_count\": 0\n" + + "}\n"; + + @Test + public void issue1426_parse() { + // com.google.gson.JsonSyntaxException: java.lang.IllegalStateException: Expected BEGIN_ARRAY but was BEGIN_OBJECT at line 65 column 14 path $.groups + File message = GsonFactory.createSnakeCase().fromJson(ISSUE_1426_JSON, File.class); + assertThat(message.getShares(), is(notNullValue())); + } +} diff --git a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java index 9bc17794b..695ee2498 100644 --- a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java +++ b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java @@ -4,6 +4,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.slack.api.model.Attachment; +import com.slack.api.model.File; import com.slack.api.model.block.ContextBlockElement; import com.slack.api.model.block.LayoutBlock; import com.slack.api.model.block.composition.TextObject; @@ -28,6 +29,7 @@ public static Gson createSnakeCaseWithoutUnknownPropertyDetection(boolean failOn public static Gson createSnakeCase(boolean failOnUnknownProperties, boolean unknownPropertyDetection) { GsonBuilder builder = new GsonBuilder() .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .registerTypeAdapter(File.class, new GsonFileFactory(failOnUnknownProperties)) .registerTypeAdapter(LayoutBlock.class, new GsonLayoutBlockFactory(failOnUnknownProperties)) .registerTypeAdapter(BlockElement.class, new GsonBlockElementFactory(failOnUnknownProperties)) .registerTypeAdapter(ContextBlockElement.class, new GsonContextBlockElementFactory(failOnUnknownProperties))