diff --git a/proto/src/main/java/com/google/gson/protobuf/ProtoTypeAdapter.java b/proto/src/main/java/com/google/gson/protobuf/ProtoTypeAdapter.java
index 7b872f1885..30146c54fc 100644
--- a/proto/src/main/java/com/google/gson/protobuf/ProtoTypeAdapter.java
+++ b/proto/src/main/java/com/google/gson/protobuf/ProtoTypeAdapter.java
@@ -89,6 +89,7 @@ public static class Builder {
private EnumSerialization enumSerialization;
private CaseFormat protoFormat;
private CaseFormat jsonFormat;
+ private boolean shouldUseJsonNameFieldOption;
private Builder(
EnumSerialization enumSerialization,
@@ -98,6 +99,7 @@ private Builder(
this.serializedEnumValueExtensions = new HashSet<>();
setEnumSerialization(enumSerialization);
setFieldNameSerializationFormat(fromFieldNameFormat, toFieldNameFormat);
+ this.shouldUseJsonNameFieldOption = false;
}
@CanIgnoreReturnValue
@@ -174,13 +176,40 @@ public Builder addSerializedEnumValueExtension(
return this;
}
+ /**
+ * Sets or unsets a flag (default false) that, when set, causes the adapter to use the {@code
+ * json_name} field option from a proto field for serialization. Unlike other field options that
+ * can be defined as annotations on a proto field, {@code json_name} cannot be accessed via a
+ * proto field's {@link FieldDescriptor#getOptions} and registered via {@link
+ * ProtoTypeAdapter.Builder#addSerializedNameExtension}.
+ *
+ *
This flag is subordinate to any custom serialized name extensions added to this adapter.
+ * In other words, serialized name extensions take precedence over this setting. For example, a
+ * field defined like:
+ *
+ *
+ * string client_app_id = 1 [json_name = "foo", (serialized_name) = "bar"];
+ *
+ *
+ * ...will be serialized as '{@code bar}' if {@code shouldUseJsonNameFieldOption} is set to
+ * {@code true} and the '{@code serialized_name}' annotation is added to the adapter.
+ *
+ * @since $next-version$
+ */
+ @CanIgnoreReturnValue
+ public Builder setShouldUseJsonNameFieldOption(boolean shouldUseJsonNameFieldOption) {
+ this.shouldUseJsonNameFieldOption = shouldUseJsonNameFieldOption;
+ return this;
+ }
+
public ProtoTypeAdapter build() {
return new ProtoTypeAdapter(
enumSerialization,
protoFormat,
jsonFormat,
serializedNameExtensions,
- serializedEnumValueExtensions);
+ serializedEnumValueExtensions,
+ shouldUseJsonNameFieldOption);
}
}
@@ -203,18 +232,21 @@ public static Builder newBuilder() {
private final CaseFormat jsonFormat;
private final Set> serializedNameExtensions;
private final Set> serializedEnumValueExtensions;
+ private final boolean shouldUseJsonNameFieldOption;
private ProtoTypeAdapter(
EnumSerialization enumSerialization,
CaseFormat protoFormat,
CaseFormat jsonFormat,
Set> serializedNameExtensions,
- Set> serializedEnumValueExtensions) {
+ Set> serializedEnumValueExtensions,
+ boolean shouldUseJsonNameFieldOption) {
this.enumSerialization = enumSerialization;
this.protoFormat = protoFormat;
this.jsonFormat = jsonFormat;
this.serializedNameExtensions = serializedNameExtensions;
this.serializedEnumValueExtensions = serializedEnumValueExtensions;
+ this.shouldUseJsonNameFieldOption = shouldUseJsonNameFieldOption;
}
@Override
@@ -224,7 +256,7 @@ public JsonElement serialize(Message src, Type typeOfSrc, JsonSerializationConte
for (Map.Entry fieldPair : fields.entrySet()) {
final FieldDescriptor desc = fieldPair.getKey();
- String name = getCustSerializedName(desc.getOptions(), desc.getName());
+ String name = getCustSerializedName(desc);
if (desc.getType() == ENUM_TYPE) {
// Enum collections are also returned as ENUM_TYPE
@@ -272,8 +304,7 @@ public Message deserialize(JsonElement json, Type typeOfT, JsonDeserializationCo
(Descriptor) getCachedMethod(protoClass, "getDescriptor").invoke(null);
// Call setters on all of the available fields
for (FieldDescriptor fieldDescriptor : protoDescriptor.getFields()) {
- String jsonFieldName =
- getCustSerializedName(fieldDescriptor.getOptions(), fieldDescriptor.getName());
+ String jsonFieldName = getCustSerializedName(fieldDescriptor);
JsonElement jsonElement = jsonObject.get(jsonFieldName);
if (jsonElement != null && !jsonElement.isJsonNull()) {
@@ -317,16 +348,20 @@ public Message deserialize(JsonElement json, Type typeOfT, JsonDeserializationCo
}
/**
- * Retrieves the custom field name from the given options, and if not found, returns the specified
- * default name.
+ * Retrieves the custom field name for a given FieldDescriptor via its field options, falling back
+ * to its name as a default.
*/
- private String getCustSerializedName(FieldOptions options, String defaultName) {
+ private String getCustSerializedName(FieldDescriptor fieldDescriptor) {
+ FieldOptions options = fieldDescriptor.getOptions();
for (Extension extension : serializedNameExtensions) {
if (options.hasExtension(extension)) {
return options.getExtension(extension);
}
}
- return protoFormat.to(jsonFormat, defaultName);
+ if (shouldUseJsonNameFieldOption && fieldDescriptor.toProto().hasJsonName()) {
+ return fieldDescriptor.getJsonName();
+ }
+ return protoFormat.to(jsonFormat, fieldDescriptor.getName());
}
/**
diff --git a/proto/src/test/java/com/google/gson/protobuf/functional/ProtosWithAnnotationsAndJsonNamesTest.java b/proto/src/test/java/com/google/gson/protobuf/functional/ProtosWithAnnotationsAndJsonNamesTest.java
new file mode 100644
index 0000000000..7eeae44f41
--- /dev/null
+++ b/proto/src/test/java/com/google/gson/protobuf/functional/ProtosWithAnnotationsAndJsonNamesTest.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2024 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 com.google.gson.protobuf.functional;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.protobuf.ProtoTypeAdapter;
+import com.google.gson.protobuf.generated.Annotations;
+import com.google.gson.protobuf.generated.Bag.ProtoWithAnnotationsAndJsonNames;
+import com.google.protobuf.GeneratedMessage;
+import java.util.Map;
+import org.junit.Test;
+
+/**
+ * Functional tests for protocol buffers using annotations and custom json_name values for field
+ * names.
+ *
+ * @author Andrew Szeto
+ */
+public class ProtosWithAnnotationsAndJsonNamesTest {
+ private static final Gson GSON_PLAIN =
+ new GsonBuilder()
+ .registerTypeHierarchyAdapter(
+ GeneratedMessage.class, ProtoTypeAdapter.newBuilder().build())
+ .create();
+ private static final Gson GSON_WITH_SERIALIZED_NAME =
+ new GsonBuilder()
+ .registerTypeHierarchyAdapter(
+ GeneratedMessage.class,
+ ProtoTypeAdapter.newBuilder()
+ .addSerializedNameExtension(Annotations.serializedName)
+ .setShouldUseJsonNameFieldOption(false)
+ .build())
+ .create();
+ private static final Gson GSON_WITH_JSON_NAME =
+ new GsonBuilder()
+ .registerTypeHierarchyAdapter(
+ GeneratedMessage.class,
+ ProtoTypeAdapter.newBuilder().setShouldUseJsonNameFieldOption(true).build())
+ .create();
+ private static final Gson GSON_WITH_SERIALIZED_NAME_AND_JSON_NAME =
+ new GsonBuilder()
+ .registerTypeHierarchyAdapter(
+ GeneratedMessage.class,
+ ProtoTypeAdapter.newBuilder()
+ .addSerializedNameExtension(Annotations.serializedName)
+ .setShouldUseJsonNameFieldOption(true)
+ .build())
+ .create();
+
+ private static final Map JSON_OUTPUTS =
+ Map.of(
+ GSON_PLAIN,
+ "{\"neither\":\"xxx\",\"jsonNameOnly\":\"yyy\",\"annotationOnly\":\"zzz\",\"both\":\"www\"}",
+ GSON_WITH_JSON_NAME,
+ "{\"neither\":\"xxx\",\"aaa\":\"yyy\",\"annotationOnly\":\"zzz\",\"ccc\":\"www\"}",
+ GSON_WITH_SERIALIZED_NAME,
+ "{\"neither\":\"xxx\",\"jsonNameOnly\":\"yyy\",\"bbb\":\"zzz\",\"ddd\":\"www\"}",
+ GSON_WITH_SERIALIZED_NAME_AND_JSON_NAME,
+ "{\"neither\":\"xxx\",\"aaa\":\"yyy\",\"bbb\":\"zzz\",\"ddd\":\"www\"}");
+
+ private static final ProtoWithAnnotationsAndJsonNames PROTO =
+ ProtoWithAnnotationsAndJsonNames.newBuilder()
+ .setNeither("xxx")
+ .setJsonNameOnly("yyy")
+ .setAnnotationOnly("zzz")
+ .setBoth("www")
+ .build();
+
+ @Test
+ public void testProtoWithAnnotationsAndJsonNames_basicConversions() {
+ JSON_OUTPUTS.forEach(
+ (gson, json) -> {
+ assertThat(gson.fromJson(json, ProtoWithAnnotationsAndJsonNames.class)).isEqualTo(PROTO);
+ assertThat(gson.toJson(PROTO)).isEqualTo(json);
+ });
+ }
+
+ @Test
+ public void testProtoWithAnnotationsAndJsonNames_basicRoundTrips() {
+ JSON_OUTPUTS.forEach(
+ (gson, json) -> {
+ assertThat(roundTrip(gson, gson, json)).isEqualTo(json);
+ assertThat(roundTrip(gson, gson, PROTO)).isEqualTo(PROTO);
+ });
+ }
+
+ @Test
+ public void testProtoWithAnnotationsAndJsonNames_unannotatedField() {
+ ProtoWithAnnotationsAndJsonNames proto =
+ ProtoWithAnnotationsAndJsonNames.newBuilder().setNeither("zzz").build();
+ String json = "{\"neither\":\"zzz\"}";
+
+ for (Gson gson1 : JSON_OUTPUTS.keySet()) {
+ for (Gson gson2 : JSON_OUTPUTS.keySet()) {
+ // all configs should match with each other in how they serialize this proto, and they
+ // should be able to deserialize any other config's serialization of the proto back to its
+ // original form
+ assertThat(gson1.toJson(proto)).isEqualTo(gson2.toJson(proto));
+ assertThat(roundTrip(gson1, gson2, proto)).isEqualTo(proto);
+ // the same, but in the other direction
+ assertThat(gson1.fromJson(json, ProtoWithAnnotationsAndJsonNames.class))
+ .isEqualTo(gson2.fromJson(json, ProtoWithAnnotationsAndJsonNames.class));
+ assertThat(roundTrip(gson1, gson2, json)).isEqualTo(json);
+ }
+ }
+ }
+
+ @Test
+ public void testProtoWithAnnotationsAndJsonNames_fieldWithJsonName() {
+ ProtoWithAnnotationsAndJsonNames proto =
+ ProtoWithAnnotationsAndJsonNames.newBuilder().setJsonNameOnly("zzz").build();
+ String jsonWithoutJsonName = "{\"jsonNameOnly\":\"zzz\"}";
+ String jsonWithJsonName = "{\"aaa\":\"zzz\"}";
+
+ // the ProtoTypeAdapter that checks for the custom annotation should default to the basic name
+ assertThat(GSON_PLAIN.toJson(proto)).isEqualTo(jsonWithoutJsonName);
+ assertThat(GSON_WITH_SERIALIZED_NAME.toJson(proto)).isEqualTo(GSON_PLAIN.toJson(proto));
+
+ // the ProtoTypeAdapter that respects the `json_name` option should not have the same output as
+ // the base case
+ assertThat(GSON_WITH_JSON_NAME.toJson(proto)).isNotEqualTo(GSON_PLAIN.toJson(proto));
+
+ // both ProtoTypeAdapters that set shouldUseJsonNameFieldOption to true should match in output
+ assertThat(GSON_WITH_JSON_NAME.toJson(proto)).isEqualTo(jsonWithJsonName);
+ assertThat(GSON_WITH_JSON_NAME.toJson(proto))
+ .isEqualTo(GSON_WITH_SERIALIZED_NAME_AND_JSON_NAME.toJson(proto));
+
+ // should fail to round-trip if we serialize via the `json_name` and deserialize without it or
+ // vice versa
+ assertThat(roundTrip(GSON_PLAIN, GSON_WITH_JSON_NAME, proto)).isNotEqualTo(proto);
+ assertThat(roundTrip(GSON_WITH_JSON_NAME, GSON_PLAIN, proto)).isNotEqualTo(proto);
+ }
+
+ @Test
+ public void testProtoWithAnnotationsAndJsonNames_fieldWithCustomSerializedName() {
+ ProtoWithAnnotationsAndJsonNames proto =
+ ProtoWithAnnotationsAndJsonNames.newBuilder().setAnnotationOnly("zzz").build();
+ String jsonWithoutCustomName = "{\"annotationOnly\":\"zzz\"}";
+ String jsonWithCustomName = "{\"bbb\":\"zzz\"}";
+
+ // the ProtoTypeAdapter that checks for the json name should default to the basic name
+ assertThat(GSON_PLAIN.toJson(proto)).isEqualTo(jsonWithoutCustomName);
+ assertThat(GSON_WITH_JSON_NAME.toJson(proto)).isEqualTo(GSON_PLAIN.toJson(proto));
+
+ // the ProtoTypeAdapter that checks for the custom serialized name should not have the same
+ // output as the base case
+ assertThat(GSON_WITH_SERIALIZED_NAME.toJson(proto)).isNotEqualTo(GSON_PLAIN.toJson(proto));
+
+ // both ProtoTypeAdapters that check for the custom serialized name should match in output
+ assertThat(GSON_WITH_SERIALIZED_NAME.toJson(proto)).isEqualTo(jsonWithCustomName);
+ assertThat(GSON_WITH_SERIALIZED_NAME.toJson(proto))
+ .isEqualTo(GSON_WITH_SERIALIZED_NAME_AND_JSON_NAME.toJson(proto));
+
+ // should fail to round-trip if we serialize via the custom name and deserialize without it or
+ // vice versa
+ assertThat(roundTrip(GSON_PLAIN, GSON_WITH_SERIALIZED_NAME, proto)).isNotEqualTo(proto);
+ assertThat(roundTrip(GSON_WITH_SERIALIZED_NAME, GSON_PLAIN, proto)).isNotEqualTo(proto);
+ }
+
+ @Test
+ public void testProtoWithAnnotationsAndJsonNames_fieldWithJsonNameAndCustomSerializedName() {
+ ProtoWithAnnotationsAndJsonNames proto =
+ ProtoWithAnnotationsAndJsonNames.newBuilder().setBoth("zzz").build();
+ String jsonPlain = "{\"both\":\"zzz\"}";
+ String jsonWithJsonName = "{\"ccc\":\"zzz\"}";
+ String jsonWithCustomName = "{\"ddd\":\"zzz\"}";
+
+ // the three different configs serialize to three different values
+ assertThat(GSON_PLAIN.toJson(proto)).isEqualTo(jsonPlain);
+ assertThat(GSON_WITH_JSON_NAME.toJson(proto)).isEqualTo(jsonWithJsonName);
+ assertThat(GSON_WITH_SERIALIZED_NAME.toJson(proto)).isEqualTo(jsonWithCustomName);
+
+ // the case where both configs are enabled will prefer the custom annotation
+ assertThat(GSON_WITH_SERIALIZED_NAME_AND_JSON_NAME.toJson(proto))
+ .isEqualTo(GSON_WITH_SERIALIZED_NAME.toJson(proto));
+ }
+
+ private static String roundTrip(Gson jsonToProto, Gson protoToJson, String json) {
+ return protoToJson.toJson(jsonToProto.fromJson(json, ProtoWithAnnotationsAndJsonNames.class));
+ }
+
+ private static ProtoWithAnnotationsAndJsonNames roundTrip(
+ Gson protoToJson, Gson jsonToProto, ProtoWithAnnotationsAndJsonNames proto) {
+ return jsonToProto.fromJson(protoToJson.toJson(proto), ProtoWithAnnotationsAndJsonNames.class);
+ }
+}
diff --git a/proto/src/test/proto/bag.proto b/proto/src/test/proto/bag.proto
index 3e4769e2a8..df3eee1890 100644
--- a/proto/src/test/proto/bag.proto
+++ b/proto/src/test/proto/bag.proto
@@ -69,4 +69,11 @@ message ProtoWithAnnotations {
}
optional InnerMessage inner_message_1 = 3;
optional InnerMessage inner_message_2 = 4;
-}
\ No newline at end of file
+}
+
+message ProtoWithAnnotationsAndJsonNames {
+ optional string neither = 1;
+ optional string json_name_only = 2 [json_name = "aaa"];
+ optional string annotation_only = 3 [(serialized_name) = "bbb"];
+ optional string both = 4 [json_name = "ccc", (serialized_name) = "ddd"];
+}