Skip to content

Commit 3621e51

Browse files
authored
Allow reading json_name field option for proto serialization (#2701)
* 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
1 parent 00028fb commit 3621e51

File tree

3 files changed

+254
-10
lines changed

3 files changed

+254
-10
lines changed

proto/src/main/java/com/google/gson/protobuf/ProtoTypeAdapter.java

+44-9
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ public static class Builder {
8989
private EnumSerialization enumSerialization;
9090
private CaseFormat protoFormat;
9191
private CaseFormat jsonFormat;
92+
private boolean shouldUseJsonNameFieldOption;
9293

9394
private Builder(
9495
EnumSerialization enumSerialization,
@@ -98,6 +99,7 @@ private Builder(
9899
this.serializedEnumValueExtensions = new HashSet<>();
99100
setEnumSerialization(enumSerialization);
100101
setFieldNameSerializationFormat(fromFieldNameFormat, toFieldNameFormat);
102+
this.shouldUseJsonNameFieldOption = false;
101103
}
102104

103105
@CanIgnoreReturnValue
@@ -174,13 +176,40 @@ public Builder addSerializedEnumValueExtension(
174176
return this;
175177
}
176178

179+
/**
180+
* Sets or unsets a flag (default false) that, when set, causes the adapter to use the {@code
181+
* json_name} field option from a proto field for serialization. Unlike other field options that
182+
* can be defined as annotations on a proto field, {@code json_name} cannot be accessed via a
183+
* proto field's {@link FieldDescriptor#getOptions} and registered via {@link
184+
* ProtoTypeAdapter.Builder#addSerializedNameExtension}.
185+
*
186+
* <p>This flag is subordinate to any custom serialized name extensions added to this adapter.
187+
* In other words, serialized name extensions take precedence over this setting. For example, a
188+
* field defined like:
189+
*
190+
* <pre>
191+
* string client_app_id = 1 [json_name = "foo", (serialized_name) = "bar"];
192+
* </pre>
193+
*
194+
* ...will be serialized as '{@code bar}' if {@code shouldUseJsonNameFieldOption} is set to
195+
* {@code true} and the '{@code serialized_name}' annotation is added to the adapter.
196+
*
197+
* @since $next-version$
198+
*/
199+
@CanIgnoreReturnValue
200+
public Builder setShouldUseJsonNameFieldOption(boolean shouldUseJsonNameFieldOption) {
201+
this.shouldUseJsonNameFieldOption = shouldUseJsonNameFieldOption;
202+
return this;
203+
}
204+
177205
public ProtoTypeAdapter build() {
178206
return new ProtoTypeAdapter(
179207
enumSerialization,
180208
protoFormat,
181209
jsonFormat,
182210
serializedNameExtensions,
183-
serializedEnumValueExtensions);
211+
serializedEnumValueExtensions,
212+
shouldUseJsonNameFieldOption);
184213
}
185214
}
186215

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

207237
private ProtoTypeAdapter(
208238
EnumSerialization enumSerialization,
209239
CaseFormat protoFormat,
210240
CaseFormat jsonFormat,
211241
Set<Extension<FieldOptions, String>> serializedNameExtensions,
212-
Set<Extension<EnumValueOptions, String>> serializedEnumValueExtensions) {
242+
Set<Extension<EnumValueOptions, String>> serializedEnumValueExtensions,
243+
boolean shouldUseJsonNameFieldOption) {
213244
this.enumSerialization = enumSerialization;
214245
this.protoFormat = protoFormat;
215246
this.jsonFormat = jsonFormat;
216247
this.serializedNameExtensions = serializedNameExtensions;
217248
this.serializedEnumValueExtensions = serializedEnumValueExtensions;
249+
this.shouldUseJsonNameFieldOption = shouldUseJsonNameFieldOption;
218250
}
219251

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

225257
for (Map.Entry<FieldDescriptor, Object> fieldPair : fields.entrySet()) {
226258
final FieldDescriptor desc = fieldPair.getKey();
227-
String name = getCustSerializedName(desc.getOptions(), desc.getName());
259+
String name = getCustSerializedName(desc);
228260

229261
if (desc.getType() == ENUM_TYPE) {
230262
// Enum collections are also returned as ENUM_TYPE
@@ -272,8 +304,7 @@ public Message deserialize(JsonElement json, Type typeOfT, JsonDeserializationCo
272304
(Descriptor) getCachedMethod(protoClass, "getDescriptor").invoke(null);
273305
// Call setters on all of the available fields
274306
for (FieldDescriptor fieldDescriptor : protoDescriptor.getFields()) {
275-
String jsonFieldName =
276-
getCustSerializedName(fieldDescriptor.getOptions(), fieldDescriptor.getName());
307+
String jsonFieldName = getCustSerializedName(fieldDescriptor);
277308

278309
JsonElement jsonElement = jsonObject.get(jsonFieldName);
279310
if (jsonElement != null && !jsonElement.isJsonNull()) {
@@ -317,16 +348,20 @@ public Message deserialize(JsonElement json, Type typeOfT, JsonDeserializationCo
317348
}
318349

319350
/**
320-
* Retrieves the custom field name from the given options, and if not found, returns the specified
321-
* default name.
351+
* Retrieves the custom field name for a given FieldDescriptor via its field options, falling back
352+
* to its name as a default.
322353
*/
323-
private String getCustSerializedName(FieldOptions options, String defaultName) {
354+
private String getCustSerializedName(FieldDescriptor fieldDescriptor) {
355+
FieldOptions options = fieldDescriptor.getOptions();
324356
for (Extension<FieldOptions, String> extension : serializedNameExtensions) {
325357
if (options.hasExtension(extension)) {
326358
return options.getExtension(extension);
327359
}
328360
}
329-
return protoFormat.to(jsonFormat, defaultName);
361+
if (shouldUseJsonNameFieldOption && fieldDescriptor.toProto().hasJsonName()) {
362+
return fieldDescriptor.getJsonName();
363+
}
364+
return protoFormat.to(jsonFormat, fieldDescriptor.getName());
330365
}
331366

332367
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/*
2+
* Copyright (C) 2024 Google Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.google.gson.protobuf.functional;
17+
18+
import static com.google.common.truth.Truth.assertThat;
19+
20+
import com.google.gson.Gson;
21+
import com.google.gson.GsonBuilder;
22+
import com.google.gson.protobuf.ProtoTypeAdapter;
23+
import com.google.gson.protobuf.generated.Annotations;
24+
import com.google.gson.protobuf.generated.Bag.ProtoWithAnnotationsAndJsonNames;
25+
import com.google.protobuf.GeneratedMessage;
26+
import java.util.Map;
27+
import org.junit.Test;
28+
29+
/**
30+
* Functional tests for protocol buffers using annotations and custom json_name values for field
31+
* names.
32+
*
33+
* @author Andrew Szeto
34+
*/
35+
public class ProtosWithAnnotationsAndJsonNamesTest {
36+
private static final Gson GSON_PLAIN =
37+
new GsonBuilder()
38+
.registerTypeHierarchyAdapter(
39+
GeneratedMessage.class, ProtoTypeAdapter.newBuilder().build())
40+
.create();
41+
private static final Gson GSON_WITH_SERIALIZED_NAME =
42+
new GsonBuilder()
43+
.registerTypeHierarchyAdapter(
44+
GeneratedMessage.class,
45+
ProtoTypeAdapter.newBuilder()
46+
.addSerializedNameExtension(Annotations.serializedName)
47+
.setShouldUseJsonNameFieldOption(false)
48+
.build())
49+
.create();
50+
private static final Gson GSON_WITH_JSON_NAME =
51+
new GsonBuilder()
52+
.registerTypeHierarchyAdapter(
53+
GeneratedMessage.class,
54+
ProtoTypeAdapter.newBuilder().setShouldUseJsonNameFieldOption(true).build())
55+
.create();
56+
private static final Gson GSON_WITH_SERIALIZED_NAME_AND_JSON_NAME =
57+
new GsonBuilder()
58+
.registerTypeHierarchyAdapter(
59+
GeneratedMessage.class,
60+
ProtoTypeAdapter.newBuilder()
61+
.addSerializedNameExtension(Annotations.serializedName)
62+
.setShouldUseJsonNameFieldOption(true)
63+
.build())
64+
.create();
65+
66+
private static final Map<Gson, String> JSON_OUTPUTS =
67+
Map.of(
68+
GSON_PLAIN,
69+
"{\"neither\":\"xxx\",\"jsonNameOnly\":\"yyy\",\"annotationOnly\":\"zzz\",\"both\":\"www\"}",
70+
GSON_WITH_JSON_NAME,
71+
"{\"neither\":\"xxx\",\"aaa\":\"yyy\",\"annotationOnly\":\"zzz\",\"ccc\":\"www\"}",
72+
GSON_WITH_SERIALIZED_NAME,
73+
"{\"neither\":\"xxx\",\"jsonNameOnly\":\"yyy\",\"bbb\":\"zzz\",\"ddd\":\"www\"}",
74+
GSON_WITH_SERIALIZED_NAME_AND_JSON_NAME,
75+
"{\"neither\":\"xxx\",\"aaa\":\"yyy\",\"bbb\":\"zzz\",\"ddd\":\"www\"}");
76+
77+
private static final ProtoWithAnnotationsAndJsonNames PROTO =
78+
ProtoWithAnnotationsAndJsonNames.newBuilder()
79+
.setNeither("xxx")
80+
.setJsonNameOnly("yyy")
81+
.setAnnotationOnly("zzz")
82+
.setBoth("www")
83+
.build();
84+
85+
@Test
86+
public void testProtoWithAnnotationsAndJsonNames_basicConversions() {
87+
JSON_OUTPUTS.forEach(
88+
(gson, json) -> {
89+
assertThat(gson.fromJson(json, ProtoWithAnnotationsAndJsonNames.class)).isEqualTo(PROTO);
90+
assertThat(gson.toJson(PROTO)).isEqualTo(json);
91+
});
92+
}
93+
94+
@Test
95+
public void testProtoWithAnnotationsAndJsonNames_basicRoundTrips() {
96+
JSON_OUTPUTS.forEach(
97+
(gson, json) -> {
98+
assertThat(roundTrip(gson, gson, json)).isEqualTo(json);
99+
assertThat(roundTrip(gson, gson, PROTO)).isEqualTo(PROTO);
100+
});
101+
}
102+
103+
@Test
104+
public void testProtoWithAnnotationsAndJsonNames_unannotatedField() {
105+
ProtoWithAnnotationsAndJsonNames proto =
106+
ProtoWithAnnotationsAndJsonNames.newBuilder().setNeither("zzz").build();
107+
String json = "{\"neither\":\"zzz\"}";
108+
109+
for (Gson gson1 : JSON_OUTPUTS.keySet()) {
110+
for (Gson gson2 : JSON_OUTPUTS.keySet()) {
111+
// all configs should match with each other in how they serialize this proto, and they
112+
// should be able to deserialize any other config's serialization of the proto back to its
113+
// original form
114+
assertThat(gson1.toJson(proto)).isEqualTo(gson2.toJson(proto));
115+
assertThat(roundTrip(gson1, gson2, proto)).isEqualTo(proto);
116+
// the same, but in the other direction
117+
assertThat(gson1.fromJson(json, ProtoWithAnnotationsAndJsonNames.class))
118+
.isEqualTo(gson2.fromJson(json, ProtoWithAnnotationsAndJsonNames.class));
119+
assertThat(roundTrip(gson1, gson2, json)).isEqualTo(json);
120+
}
121+
}
122+
}
123+
124+
@Test
125+
public void testProtoWithAnnotationsAndJsonNames_fieldWithJsonName() {
126+
ProtoWithAnnotationsAndJsonNames proto =
127+
ProtoWithAnnotationsAndJsonNames.newBuilder().setJsonNameOnly("zzz").build();
128+
String jsonWithoutJsonName = "{\"jsonNameOnly\":\"zzz\"}";
129+
String jsonWithJsonName = "{\"aaa\":\"zzz\"}";
130+
131+
// the ProtoTypeAdapter that checks for the custom annotation should default to the basic name
132+
assertThat(GSON_PLAIN.toJson(proto)).isEqualTo(jsonWithoutJsonName);
133+
assertThat(GSON_WITH_SERIALIZED_NAME.toJson(proto)).isEqualTo(GSON_PLAIN.toJson(proto));
134+
135+
// the ProtoTypeAdapter that respects the `json_name` option should not have the same output as
136+
// the base case
137+
assertThat(GSON_WITH_JSON_NAME.toJson(proto)).isNotEqualTo(GSON_PLAIN.toJson(proto));
138+
139+
// both ProtoTypeAdapters that set shouldUseJsonNameFieldOption to true should match in output
140+
assertThat(GSON_WITH_JSON_NAME.toJson(proto)).isEqualTo(jsonWithJsonName);
141+
assertThat(GSON_WITH_JSON_NAME.toJson(proto))
142+
.isEqualTo(GSON_WITH_SERIALIZED_NAME_AND_JSON_NAME.toJson(proto));
143+
144+
// should fail to round-trip if we serialize via the `json_name` and deserialize without it or
145+
// vice versa
146+
assertThat(roundTrip(GSON_PLAIN, GSON_WITH_JSON_NAME, proto)).isNotEqualTo(proto);
147+
assertThat(roundTrip(GSON_WITH_JSON_NAME, GSON_PLAIN, proto)).isNotEqualTo(proto);
148+
}
149+
150+
@Test
151+
public void testProtoWithAnnotationsAndJsonNames_fieldWithCustomSerializedName() {
152+
ProtoWithAnnotationsAndJsonNames proto =
153+
ProtoWithAnnotationsAndJsonNames.newBuilder().setAnnotationOnly("zzz").build();
154+
String jsonWithoutCustomName = "{\"annotationOnly\":\"zzz\"}";
155+
String jsonWithCustomName = "{\"bbb\":\"zzz\"}";
156+
157+
// the ProtoTypeAdapter that checks for the json name should default to the basic name
158+
assertThat(GSON_PLAIN.toJson(proto)).isEqualTo(jsonWithoutCustomName);
159+
assertThat(GSON_WITH_JSON_NAME.toJson(proto)).isEqualTo(GSON_PLAIN.toJson(proto));
160+
161+
// the ProtoTypeAdapter that checks for the custom serialized name should not have the same
162+
// output as the base case
163+
assertThat(GSON_WITH_SERIALIZED_NAME.toJson(proto)).isNotEqualTo(GSON_PLAIN.toJson(proto));
164+
165+
// both ProtoTypeAdapters that check for the custom serialized name should match in output
166+
assertThat(GSON_WITH_SERIALIZED_NAME.toJson(proto)).isEqualTo(jsonWithCustomName);
167+
assertThat(GSON_WITH_SERIALIZED_NAME.toJson(proto))
168+
.isEqualTo(GSON_WITH_SERIALIZED_NAME_AND_JSON_NAME.toJson(proto));
169+
170+
// should fail to round-trip if we serialize via the custom name and deserialize without it or
171+
// vice versa
172+
assertThat(roundTrip(GSON_PLAIN, GSON_WITH_SERIALIZED_NAME, proto)).isNotEqualTo(proto);
173+
assertThat(roundTrip(GSON_WITH_SERIALIZED_NAME, GSON_PLAIN, proto)).isNotEqualTo(proto);
174+
}
175+
176+
@Test
177+
public void testProtoWithAnnotationsAndJsonNames_fieldWithJsonNameAndCustomSerializedName() {
178+
ProtoWithAnnotationsAndJsonNames proto =
179+
ProtoWithAnnotationsAndJsonNames.newBuilder().setBoth("zzz").build();
180+
String jsonPlain = "{\"both\":\"zzz\"}";
181+
String jsonWithJsonName = "{\"ccc\":\"zzz\"}";
182+
String jsonWithCustomName = "{\"ddd\":\"zzz\"}";
183+
184+
// the three different configs serialize to three different values
185+
assertThat(GSON_PLAIN.toJson(proto)).isEqualTo(jsonPlain);
186+
assertThat(GSON_WITH_JSON_NAME.toJson(proto)).isEqualTo(jsonWithJsonName);
187+
assertThat(GSON_WITH_SERIALIZED_NAME.toJson(proto)).isEqualTo(jsonWithCustomName);
188+
189+
// the case where both configs are enabled will prefer the custom annotation
190+
assertThat(GSON_WITH_SERIALIZED_NAME_AND_JSON_NAME.toJson(proto))
191+
.isEqualTo(GSON_WITH_SERIALIZED_NAME.toJson(proto));
192+
}
193+
194+
private static String roundTrip(Gson jsonToProto, Gson protoToJson, String json) {
195+
return protoToJson.toJson(jsonToProto.fromJson(json, ProtoWithAnnotationsAndJsonNames.class));
196+
}
197+
198+
private static ProtoWithAnnotationsAndJsonNames roundTrip(
199+
Gson protoToJson, Gson jsonToProto, ProtoWithAnnotationsAndJsonNames proto) {
200+
return jsonToProto.fromJson(protoToJson.toJson(proto), ProtoWithAnnotationsAndJsonNames.class);
201+
}
202+
}

proto/src/test/proto/bag.proto

+8-1
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,11 @@ message ProtoWithAnnotations {
6969
}
7070
optional InnerMessage inner_message_1 = 3;
7171
optional InnerMessage inner_message_2 = 4;
72-
}
72+
}
73+
74+
message ProtoWithAnnotationsAndJsonNames {
75+
optional string neither = 1;
76+
optional string json_name_only = 2 [json_name = "aaa"];
77+
optional string annotation_only = 3 [(serialized_name) = "bbb"];
78+
optional string both = 4 [json_name = "ccc", (serialized_name) = "ddd"];
79+
}

0 commit comments

Comments
 (0)