From c71bde46605524f6b8b21fac89f312d8efbc81e2 Mon Sep 17 00:00:00 2001 From: Andrii Bodnar <29282228+andrii-bodnar@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:17:52 +0300 Subject: [PATCH] fix: deserialize empty-array permissions in translator roles Project members whose role has no per-language permissions are returned with "languagesAccess": [] (and sometimes "permissions": []) instead of an object, which failed with "Cannot deserialize value of type LinkedHashMap from Array value". - annotate TranslatorRolePermissions.languagesAccess and TranslatorRole.permissions with EmptyArrayToNullDeserializer - make EmptyArrayToNullDeserializer read with the full JavaType so generic parameters are preserved (previously Map values came back as LinkedHashMap instead of the declared value type) - add language_coordinator, developer and manager to TranslatorRoleName so those role names no longer fail the whole response Co-Authored-By: Claude Fable 5.1 --- .../json/EmptyArrayToNullDeserializer.java | 13 ++--- .../client/users/model/TranslatorRole.java | 3 + .../users/model/TranslatorRoleName.java | 2 +- .../model/TranslatorRolePermissions.java | 3 + .../EmptyArrayToNullDeserializerTest.java | 24 ++++++++ .../crowdin/client/users/UsersApiTest.java | 20 +++++++ ...jectMembersEnterpriseEmptyPermissions.json | 57 +++++++++++++++++++ 7 files changed, 113 insertions(+), 9 deletions(-) create mode 100644 src/test/resources/api/users/listProjectMembersEnterpriseEmptyPermissions.json diff --git a/src/main/java/com/crowdin/client/core/http/impl/json/EmptyArrayToNullDeserializer.java b/src/main/java/com/crowdin/client/core/http/impl/json/EmptyArrayToNullDeserializer.java index 24372df74..5bdbdb255 100644 --- a/src/main/java/com/crowdin/client/core/http/impl/json/EmptyArrayToNullDeserializer.java +++ b/src/main/java/com/crowdin/client/core/http/impl/json/EmptyArrayToNullDeserializer.java @@ -29,16 +29,13 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOEx Class clazz = this.type != null ? this.type.getRawClass() : Object.class; - if (p.getCurrentToken() == JsonToken.START_ARRAY) { - if (!isCollectionType(clazz)) { - p.nextToken(); - return null; - } else { - return ctxt.readValue(p, clazz); - } + if (p.getCurrentToken() == JsonToken.START_ARRAY && !isCollectionType(clazz)) { + p.skipChildren(); + return null; } - return ctxt.readValue(p, clazz); + // read with the full JavaType so generic parameters (e.g. Map) are preserved + return this.type != null ? ctxt.readValue(p, this.type) : ctxt.readValue(p, clazz); } private static boolean isCollectionType(Class type) { diff --git a/src/main/java/com/crowdin/client/users/model/TranslatorRole.java b/src/main/java/com/crowdin/client/users/model/TranslatorRole.java index facb74626..0d2cbda9f 100644 --- a/src/main/java/com/crowdin/client/users/model/TranslatorRole.java +++ b/src/main/java/com/crowdin/client/users/model/TranslatorRole.java @@ -1,9 +1,12 @@ package com.crowdin.client.users.model; +import com.crowdin.client.core.http.impl.json.EmptyArrayToNullDeserializer; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import lombok.Data; @Data public class TranslatorRole { private TranslatorRoleName name; + @JsonDeserialize(using = EmptyArrayToNullDeserializer.class) private TranslatorRolePermissions permissions; } diff --git a/src/main/java/com/crowdin/client/users/model/TranslatorRoleName.java b/src/main/java/com/crowdin/client/users/model/TranslatorRoleName.java index 5b16435f8..ca56a9b2e 100644 --- a/src/main/java/com/crowdin/client/users/model/TranslatorRoleName.java +++ b/src/main/java/com/crowdin/client/users/model/TranslatorRoleName.java @@ -3,7 +3,7 @@ import com.crowdin.client.core.model.EnumConverter; public enum TranslatorRoleName implements EnumConverter { - TRANSLATOR, PROOFREADER, OWNER; + TRANSLATOR, PROOFREADER, LANGUAGE_COORDINATOR, DEVELOPER, MANAGER, OWNER; public static TranslatorRoleName from(String value){ return TranslatorRoleName.valueOf(value.toUpperCase()); diff --git a/src/main/java/com/crowdin/client/users/model/TranslatorRolePermissions.java b/src/main/java/com/crowdin/client/users/model/TranslatorRolePermissions.java index 11b93f20e..fa532ecd2 100644 --- a/src/main/java/com/crowdin/client/users/model/TranslatorRolePermissions.java +++ b/src/main/java/com/crowdin/client/users/model/TranslatorRolePermissions.java @@ -1,6 +1,8 @@ package com.crowdin.client.users.model; +import com.crowdin.client.core.http.impl.json.EmptyArrayToNullDeserializer; import com.crowdin.client.core.model.LanguageAccessRule; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import lombok.Data; import java.util.Map; @@ -8,5 +10,6 @@ @Data public class TranslatorRolePermissions { private boolean allLanguages; + @JsonDeserialize(using = EmptyArrayToNullDeserializer.class) private Map languagesAccess; } diff --git a/src/test/java/com/crowdin/client/core/http/impl/json/EmptyArrayToNullDeserializerTest.java b/src/test/java/com/crowdin/client/core/http/impl/json/EmptyArrayToNullDeserializerTest.java index 4fcbc066b..2e131cd19 100644 --- a/src/test/java/com/crowdin/client/core/http/impl/json/EmptyArrayToNullDeserializerTest.java +++ b/src/test/java/com/crowdin/client/core/http/impl/json/EmptyArrayToNullDeserializerTest.java @@ -6,7 +6,9 @@ import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.type.TypeFactory; +import com.crowdin.client.core.model.LanguageAccessRule; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -16,6 +18,7 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.*; @@ -151,4 +154,25 @@ public void testDeserializeIfNull() throws IOException { assertNull(result); } + public static class MapHolder { + @JsonDeserialize(using = EmptyArrayToNullDeserializer.class) + public Map rules; + } + + @Test + public void testDeserializeEmptyArrayAsMapReturnsNull() throws IOException { + MapHolder holder = new ObjectMapper().readValue("{\"rules\": []}", MapHolder.class); + + assertNull(holder.rules); + } + + @Test + public void testDeserializeObjectAsMapPreservesGenericValueType() throws IOException { + MapHolder holder = new ObjectMapper().readValue("{\"rules\": {\"uk\": {\"allContent\": true}}}", MapHolder.class); + + assertNotNull(holder.rules); + LanguageAccessRule rule = holder.rules.get("uk"); + assertTrue(rule.isAllContent()); + } + } diff --git a/src/test/java/com/crowdin/client/users/UsersApiTest.java b/src/test/java/com/crowdin/client/users/UsersApiTest.java index 09a6201eb..8c532e883 100644 --- a/src/test/java/com/crowdin/client/users/UsersApiTest.java +++ b/src/test/java/com/crowdin/client/users/UsersApiTest.java @@ -25,6 +25,7 @@ public class UsersApiTest extends TestClient { private final Long projectId4 = 15L; private final Long projectId5 = 16L; private final Long projectId6 = 17L; + private final Long projectId7 = 18L; private final Long userId = 1L; private final Long user2Id = 2L; @@ -56,6 +57,7 @@ public List getMocks() { RequestMock.build(this.url + "/groups/" + groupId + "/managers", HttpPatch.METHOD_NAME, "api/users/editGroupManagers.json", "api/users/listGroupManagers.json"), RequestMock.build(this.url + "/groups/" + groupId + "/managers/" + userId, HttpGet.METHOD_NAME, "api/users/groupManager.json"), RequestMock.build(String.format("%s/projects/%d/members", this.url, projectId2), HttpGet.METHOD_NAME, "api/users/listProjectMembersEnterprise.json"), + RequestMock.build(String.format("%s/projects/%d/members", this.url, projectId7), HttpGet.METHOD_NAME, "api/users/listProjectMembersEnterpriseEmptyPermissions.json"), RequestMock.build(String.format("%s/projects/%d/members", this.url, projectId3), HttpGet.METHOD_NAME, "api/users/listProjectMembersEnterpriseOrderByIdAsc.json", new HashMap() {{ put("orderBy", "id%20asc"); }}), @@ -205,6 +207,24 @@ public void listProjectTeamMembersEnterpriseTest_orderByNull() { assertTrue(role.getPermissions().isAllLanguages()); } + @Test + public void listProjectTeamMembersEnterpriseTest_emptyPermissions() { + ResponseList responseList = this.getUsersApi().listProjectMembersEnterprise(this.projectId7, null, null, null, null, null); + assertNotNull(responseList); + assertEquals(3, responseList.getData().size()); + + TranslatorRole emptyLanguagesAccess = responseList.getData().get(0).getData().getRoles().get(0); + assertEquals(TranslatorRoleName.DEVELOPER, emptyLanguagesAccess.getName()); + assertFalse(emptyLanguagesAccess.getPermissions().isAllLanguages()); + assertNull(emptyLanguagesAccess.getPermissions().getLanguagesAccess()); + + TranslatorRole emptyPermissions = responseList.getData().get(1).getData().getRoles().get(0); + assertEquals(TranslatorRoleName.LANGUAGE_COORDINATOR, emptyPermissions.getName()); + assertNull(emptyPermissions.getPermissions()); + + assertTrue(responseList.getData().get(2).getData().getRoles().isEmpty()); + } + @Test public void listProjectTeamMembersEnterpriseTest_orderByIdNull() { OrderByField orderById = new OrderByField(); diff --git a/src/test/resources/api/users/listProjectMembersEnterpriseEmptyPermissions.json b/src/test/resources/api/users/listProjectMembersEnterpriseEmptyPermissions.json new file mode 100644 index 000000000..9b1134c14 --- /dev/null +++ b/src/test/resources/api/users/listProjectMembersEnterpriseEmptyPermissions.json @@ -0,0 +1,57 @@ +{ + "data": [ + { + "data": { + "id": 1, + "username": "john_smith", + "firstName": "John", + "lastName": "Smith", + "isManager": false, + "isDeveloper": true, + "roles": [ + { + "name": "developer", + "permissions": { + "allLanguages": false, + "languagesAccess": [] + } + } + ], + "givenAccessAt": "2019-10-23T11:44:02+00:00" + } + }, + { + "data": { + "id": 2, + "username": "jane_doe", + "firstName": "Jane", + "lastName": "Doe", + "isManager": false, + "isDeveloper": false, + "roles": [ + { + "name": "language_coordinator", + "permissions": [] + } + ], + "givenAccessAt": "2019-10-23T11:44:02+00:00" + } + }, + { + "data": { + "id": 3, + "username": "admin", + "firstName": "Ad", + "lastName": "Min", + "isManager": true, + "isDeveloper": false, + "roles": [], + "givenAccessAt": "2019-10-23T11:44:02+00:00" + } + } + ], + "pagination": { + "offset": 0, + "limit": 25 + } +}