Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JSON serialization emitDefaults option #592

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions protobuf/lib/protobuf.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import 'dart:typed_data' show TypedData, Uint8List, ByteData, Endian;
import 'package:fixnum/fixnum.dart' show Int64;

import 'src/protobuf/json_parsing_context.dart';
import 'src/protobuf/json_serialization_context.dart';
import 'src/protobuf/permissive_compare.dart';
import 'src/protobuf/type_registry.dart';
export 'src/protobuf/type_registry.dart' show TypeRegistry;
Expand Down
5 changes: 3 additions & 2 deletions protobuf/lib/src/protobuf/generated_message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,9 @@ abstract class GeneratedMessage {
/// message encoding a type not in [typeRegistry] is encountered, an
/// error is thrown.
Object? toProto3Json(
{TypeRegistry typeRegistry = const TypeRegistry.empty()}) =>
_writeToProto3Json(_fieldSet, typeRegistry);
{TypeRegistry typeRegistry = const TypeRegistry.empty(),
bool emitDefaults = false}) =>
_writeToProto3Json(_fieldSet, typeRegistry, emitDefaults);

/// Merges field values from [json], a JSON object using proto3 encoding.
///
Expand Down
9 changes: 9 additions & 0 deletions protobuf/lib/src/protobuf/json_serialization_context.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

class JsonSerializationContext {
final bool emitDefaults;

JsonSerializationContext(this.emitDefaults);
}
34 changes: 27 additions & 7 deletions protobuf/lib/src/protobuf/proto3_json.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

part of protobuf;

Object? _writeToProto3Json(_FieldSet fs, TypeRegistry typeRegistry) {
Object? _writeToProto3Json(
_FieldSet fs, TypeRegistry typeRegistry, bool emitDefaults) {
var context = JsonSerializationContext(emitDefaults);

String? convertToMapKey(dynamic key, int keyType) {
var baseType = PbFieldType._baseType(keyType);

Expand Down Expand Up @@ -36,8 +39,8 @@ Object? _writeToProto3Json(_FieldSet fs, TypeRegistry typeRegistry) {
if (fieldValue == null) return null;

if (_isGroupOrMessage(fieldType!)) {
return _writeToProto3Json(
(fieldValue as GeneratedMessage)._fieldSet, typeRegistry);
return _writeToProto3Json((fieldValue as GeneratedMessage)._fieldSet,
typeRegistry, context.emitDefaults);
} else if (_isEnum(fieldType)) {
return (fieldValue as ProtobufEnum).name;
} else {
Expand Down Expand Up @@ -81,6 +84,14 @@ Object? _writeToProto3Json(_FieldSet fs, TypeRegistry typeRegistry) {
}
}

bool isDefaultListField(dynamic value) {
return value == null || (value is List && value.isEmpty);
}
jmartin127 marked this conversation as resolved.
Show resolved Hide resolved

bool isDefaultMapField(dynamic value) {
return value == null || (value is Map && value.isEmpty);
}
jmartin127 marked this conversation as resolved.
Show resolved Hide resolved

final meta = fs._meta;
if (meta.toProto3Json != null) {
return meta.toProto3Json!(fs._message!, typeRegistry);
Expand All @@ -89,11 +100,20 @@ Object? _writeToProto3Json(_FieldSet fs, TypeRegistry typeRegistry) {
var result = <String, dynamic>{};
for (var fieldInfo in fs._infosSortedByTag) {
var value = fs._values[fieldInfo.index!];
if (value == null || (value is List && value.isEmpty)) {
continue; // It's missing, repeated, or an empty byte array.
}
dynamic jsonValue;
if (fieldInfo.isMapField) {
if (context.emitDefaults &&
((fieldInfo.isRepeated && isDefaultListField(value)) ||
(fieldInfo.isMapField && isDefaultMapField(value)))) {
jsonValue = fieldInfo.readonlyDefault;
} else if (context.emitDefaults &&
((_isBytes(fieldInfo.type) && isDefaultListField(value)) ||
(_isGroupOrMessage(fieldInfo.type) && value == null))) {
jsonValue = null;
} else if (context.emitDefaults && value == null) {
jsonValue = valueToProto3Json(fieldInfo.readonlyDefault, fieldInfo.type);
} else if (isDefaultListField(value)) {
continue; // It's missing, repeated, or an empty byte array.
} else if (fieldInfo.isMapField) {
jmartin127 marked this conversation as resolved.
Show resolved Hide resolved
jsonValue = (value as PbMap).map((key, entryValue) {
var mapEntryInfo = fieldInfo as MapFieldInfo;
return MapEntry(convertToMapKey(key, mapEntryInfo.keyFieldType!),
Expand Down
132 changes: 132 additions & 0 deletions protobuf/test/json_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ library json_test;
import 'dart:convert';

import 'package:fixnum/fixnum.dart' show Int64;
import 'package:protobuf/protobuf.dart';
import 'package:test/test.dart';

import 'mock_util.dart' show T, mockEnumValues;
Expand All @@ -16,6 +17,32 @@ void main() {
..str = 'hello'
..int32s.addAll(<int>[1, 2, 3]);

final double doubleZeroVal = 0;
final exampleAllDefaults = T()
..val = 0
..str = ''
..child = T()
..int64 = Int64(0)
..enm = ProtobufEnum(1, 'a')
..dbl = doubleZeroVal
..bl = false
..byts = <int>[]
..int32s.addAll(<int>[]);
exampleAllDefaults.mp.addAll(<String, String>{});

final double doubleSetVal = 1.34;
final exampleAllSet = T()
..val = 32
..str = 'the-string'
..child = example
..int64 = Int64(78)
..enm = ProtobufEnum(1, 'b')
..dbl = doubleSetVal
..bl = true
..byts = <int>[46, 28]
..int32s.addAll(<int>[3, 4, 6]);
exampleAllSet.mp.addAll(<String, String>{'k1': 'v2'});

test('testProto3JsonEnum', () {
// No enum value specified.
expect(example.hasEnm, isFalse);
Expand Down Expand Up @@ -115,6 +142,111 @@ void main() {
final decoded = T()..mergeFromJsonMap(encoded);
expect(decoded.int64, value);
});

test('testToProto3Json', () {
var json = jsonEncode(example.toProto3Json());
checkProto3JsonMap(jsonDecode(json), 3);
});

test('testToProto3JsonNoFieldsSet', () {
final json = jsonEncode(T().toProto3Json());
expect(json.contains('{}'), isTrue);
final Map m = jsonDecode(json);
expect(m.isEmpty, isTrue);
});

test('testToProto3JsonFieldsSetToDefaults', () {
final json = jsonEncode(exampleAllDefaults.toProto3Json());
expect(json.contains('"child":{}'), isTrue);
final Map m = jsonDecode(json);
expect(m.isEmpty, isFalse);
});

test('testToProto3JsonFieldsSetToValues', () {
// verify the expected child is present
final json = jsonEncode(exampleAllSet.toProto3Json());
final expectedChild = '{"val":123,"str":"hello","int32s":[1,2,3]}';
expect(json.contains('"child":$expectedChild'), isTrue);
final jsonNoChild = json.replaceAll(expectedChild, '');

// verify the remaining parent object is accurate
checkExampleAllParentSetValues(jsonNoChild);
});

test('testToProto3JsonEmitDefaults', () {
final json = jsonEncode(example.toProto3Json(emitDefaults: true));
checkProto3JsonMap(jsonDecode(json), 10);
});

test('testToProto3JsonEmitDefaultsNoFieldsSet', () {
final json = jsonEncode(T().toProto3Json(emitDefaults: true));
expect(json.contains('"val":42,'), isTrue);
expect(json.contains('"str":"",'), isTrue);
expect(json.contains('"child":null,'), isTrue);
expect(json.contains('"int32s":[],'), isTrue);
expect(json.contains('"int64":"0",'), isTrue);
expect(json.contains('"enm":"a",'), isTrue);
expect(json.contains('"dbl":0.0,'), isTrue);
expect(json.contains('"bl":false,'), isTrue);
expect(json.contains('"byts":null'), isTrue);
expect(json.contains('"mp":{}'), isTrue);
});

test('testToProto3JsonEmitDefaultsFieldsSetToDefaults', () {
// verify the default child is present
final json =
jsonEncode(exampleAllDefaults.toProto3Json(emitDefaults: true));
final defaultChild =
'{"val":42,"str":"","child":null,"int32s":[],"int64":"0","enm":"a","dbl":0.0,"bl":false,"byts":null,"mp":{}}';
expect(json.contains('"child":$defaultChild'), isTrue);
final jsonNoChild = json.replaceAll(defaultChild, '');

// verify the remaining parent object is accurate
checkExampleAllParentSetDefaults(jsonNoChild);
});

test('testToProto3JsonEmitDefaultsFieldsSetToValues', () {
// verify the expected child is present
final json = jsonEncode(exampleAllSet.toProto3Json(emitDefaults: true));
final expectedChild =
'{"val":123,"str":"hello","child":null,"int32s":[1,2,3],"int64":"0","enm":"a","dbl":0.0,"bl":false,"byts":null,"mp":{}}';
expect(json.contains('"child":$expectedChild'), isTrue);
final jsonNoChild = json.replaceAll(expectedChild, '');

// verify the remaining parent object is accurate
checkExampleAllParentSetValues(jsonNoChild);
});
}

void checkExampleAllParentSetDefaults(String json) {
expect(json.contains('"val":0,'), isTrue);
expect(json.contains('"str":"",'), isTrue);
expect(json.contains('"int32s":[],'), isTrue);
expect(json.contains('"int64":"0",'), isTrue);
expect(json.contains('"enm":"a",'), isTrue);
expect(json.contains('"dbl":0.0,'), isTrue);
expect(json.contains('"bl":false,'), isTrue);
expect(json.contains('"byts":null'), isTrue);
expect(json.contains('"mp":{}'), isTrue);
}

void checkExampleAllParentSetValues(String json) {
expect(json.contains('"val":32,'), isTrue);
expect(json.contains('"str":"the-string",'), isTrue);
expect(json.contains('"int32s":[3,4,6],'), isTrue);
expect(json.contains('"int64":"78",'), isTrue);
expect(json.contains('"enm":"b",'), isTrue);
expect(json.contains('"dbl":1.34,'), isTrue);
expect(json.contains('"bl":true,'), isTrue);
expect(json.contains('"byts":"Lhw="'), isTrue);
expect(json.contains('"mp":{"k1":"v2"}'), isTrue);
}

void checkProto3JsonMap(Map m, int expectedLength) {
expect(m.length, expectedLength);
expect(m['val'], 123);
expect(m['str'], 'hello');
expect(m['int32s'], [1, 2, 3]);
}

void checkJsonMap(Map m) {
Expand Down
15 changes: 13 additions & 2 deletions protobuf/test/map_mixin_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,18 @@ void main() {

expect(r.isEmpty, false);
expect(r.isNotEmpty, true);
expect(r.keys, ['val', 'str', 'child', 'int32s', 'int64', 'enm']);
expect(r.keys, [
'val',
'str',
'child',
'int32s',
'int64',
'enm',
'dbl',
'bl',
'byts',
'mp'
]);

expect(r['val'], 42);
expect(r['str'], '');
Expand All @@ -42,7 +53,7 @@ void main() {
expect(r['int32s'], []);

var v = r.values;
expect(v.length, 6);
expect(v.length, 10);
expect(v.first, 42);
expect(v.toList()[1], '');
expect(v.toList()[3].toString(), '[]');
Expand Down
19 changes: 18 additions & 1 deletion protobuf/test/mock_util.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ BuilderInfo mockInfo(String className, CreateBuilderFunc create) {
..e(7, 'enm', PbFieldType.OE,
defaultOrMaker: mockEnumValues.first,
valueOf: (i) => mockEnumValues.firstWhereOrNull((e) => e.value == i),
enumValues: mockEnumValues);
enumValues: mockEnumValues)
..a(8, 'dbl', PbFieldType.OD)
..aOB(9, 'bl')
..a<int>(10, 'byts', PbFieldType.OY)
..m<String, String>(11, 'mp',
keyFieldType: PbFieldType.OS, valueFieldType: PbFieldType.OS);
}

/// A minimal protobuf implementation for testing.
Expand All @@ -49,6 +54,18 @@ abstract class MockMessage extends GeneratedMessage {

ProtobufEnum get enm => $_getN(5);
bool get hasEnm => $_has(5);
set enm(x) => setField(7, x);

double get dbl => $_get(6, 0.0);
set dbl(x) => setField(8, x);

bool get bl => $_get(7, false);
set bl(x) => setField(9, x);

List<int> get byts => $_get(8, []);
set byts(x) => setField(10, x);

Map<String, String> get mp => $_getMap(9);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like you don't try to set the map field in the tests. Is there a reason for this? To make sure we handle maps correctly we should set the map field too.

Copy link
Author

@jmartin127 jmartin127 Apr 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The map value is getting set, just in a different way... For consistency, I followed the existing example of how the int32s are set using the addAll function.

See here for an example of setting/testing the map field.
https://github.com/google/protobuf.dart/pull/592/files#diff-f9dfd1b28cb8ed5241edf3d57212698aeb5c6b3faa64d982213236cba5bd89f8R44

With this in mind, it is currently being tested, but if it would be better to set the map differently, just let me know.


@override
GeneratedMessage clone() {
Expand Down