Skip to content

Commit

Permalink
Allow reading json_name field option for proto serialization (#2701)
Browse files Browse the repository at this point in the history
* Change the parameters for the `getCustSerializedName(FieldDescriptor)` method

In the other commits in this PR, I plan to introduce branching logic inside of
the customization of the serialized name for fields. This change is a pure
refactor that serves to isolate the business logic into a separate commit so as
to make it easier to understand.

* Allow reading `json_name` field option for proto serialization

* Add tests for reading `json_name` field option

* Add some metadata to Javadoc according to contributing guidelines

* Remove @author annotation in Javadoc

* Update branch based on PR feedback on GitHub

* Update copyright year on test file
  • Loading branch information
jabagawee authored Jun 22, 2024
1 parent 00028fb commit 3621e51
Show file tree
Hide file tree
Showing 3 changed files with 254 additions and 10 deletions.
53 changes: 44 additions & 9 deletions proto/src/main/java/com/google/gson/protobuf/ProtoTypeAdapter.java
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ public static class Builder {
private EnumSerialization enumSerialization;
private CaseFormat protoFormat;
private CaseFormat jsonFormat;
private boolean shouldUseJsonNameFieldOption;

private Builder(
EnumSerialization enumSerialization,
Expand All @@ -98,6 +99,7 @@ private Builder(
this.serializedEnumValueExtensions = new HashSet<>();
setEnumSerialization(enumSerialization);
setFieldNameSerializationFormat(fromFieldNameFormat, toFieldNameFormat);
this.shouldUseJsonNameFieldOption = false;
}

@CanIgnoreReturnValue
Expand Down Expand Up @@ -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}.
*
* <p>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:
*
* <pre>
* string client_app_id = 1 [json_name = "foo", (serialized_name) = "bar"];
* </pre>
*
* ...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);
}
}

Expand All @@ -203,18 +232,21 @@ public static Builder newBuilder() {
private final CaseFormat jsonFormat;
private final Set<Extension<FieldOptions, String>> serializedNameExtensions;
private final Set<Extension<EnumValueOptions, String>> serializedEnumValueExtensions;
private final boolean shouldUseJsonNameFieldOption;

private ProtoTypeAdapter(
EnumSerialization enumSerialization,
CaseFormat protoFormat,
CaseFormat jsonFormat,
Set<Extension<FieldOptions, String>> serializedNameExtensions,
Set<Extension<EnumValueOptions, String>> serializedEnumValueExtensions) {
Set<Extension<EnumValueOptions, String>> serializedEnumValueExtensions,
boolean shouldUseJsonNameFieldOption) {
this.enumSerialization = enumSerialization;
this.protoFormat = protoFormat;
this.jsonFormat = jsonFormat;
this.serializedNameExtensions = serializedNameExtensions;
this.serializedEnumValueExtensions = serializedEnumValueExtensions;
this.shouldUseJsonNameFieldOption = shouldUseJsonNameFieldOption;
}

@Override
Expand All @@ -224,7 +256,7 @@ public JsonElement serialize(Message src, Type typeOfSrc, JsonSerializationConte

for (Map.Entry<FieldDescriptor, Object> 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
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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<FieldOptions, String> 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());
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Gson, String> 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);
}
}
9 changes: 8 additions & 1 deletion proto/src/test/proto/bag.proto
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,11 @@ message ProtoWithAnnotations {
}
optional InnerMessage inner_message_1 = 3;
optional InnerMessage inner_message_2 = 4;
}
}

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"];
}

0 comments on commit 3621e51

Please sign in to comment.