diff --git a/protobuf/lib/protobuf.dart b/protobuf/lib/protobuf.dart index e2d5cdcb2..bed6b135e 100644 --- a/protobuf/lib/protobuf.dart +++ b/protobuf/lib/protobuf.dart @@ -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; diff --git a/protobuf/lib/src/protobuf/generated_message.dart b/protobuf/lib/src/protobuf/generated_message.dart index 1c7359774..504b07ea3 100644 --- a/protobuf/lib/src/protobuf/generated_message.dart +++ b/protobuf/lib/src/protobuf/generated_message.dart @@ -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. /// diff --git a/protobuf/lib/src/protobuf/json_serialization_context.dart b/protobuf/lib/src/protobuf/json_serialization_context.dart new file mode 100644 index 000000000..1fdab29c5 --- /dev/null +++ b/protobuf/lib/src/protobuf/json_serialization_context.dart @@ -0,0 +1,18 @@ +// 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); +} + +class JsonSerializationException implements Exception { + final String message; + + JsonSerializationException(this.message); + + @override + String toString() => message; +} diff --git a/protobuf/lib/src/protobuf/proto3_json.dart b/protobuf/lib/src/protobuf/proto3_json.dart index 6d35db920..0ab824e72 100644 --- a/protobuf/lib/src/protobuf/proto3_json.dart +++ b/protobuf/lib/src/protobuf/proto3_json.dart @@ -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); @@ -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 { @@ -88,25 +91,90 @@ Object? _writeToProto3Json(_FieldSet fs, TypeRegistry typeRegistry) { var result = {}; 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) { - jsonValue = (value as PbMap).map((key, entryValue) { - var mapEntryInfo = fieldInfo as MapFieldInfo; - return MapEntry(convertToMapKey(key, mapEntryInfo.keyFieldType!), - valueToProto3Json(entryValue, mapEntryInfo.valueFieldType)); - }); - } else if (fieldInfo.isRepeated) { - jsonValue = (value as PbListBase) - .map((element) => valueToProto3Json(element, fieldInfo.type)) - .toList(); + // if the value for this field is null, this function will return the + // default value, which simplifies the need to handle null for most cases + // below + var value = fs._getField(fieldInfo.tagNumber); + + bool skipField = true; + Object? jsonValue; + if (fieldInfo.isRepeated) { + if (context.emitDefaults || (value as List).isNotEmpty) { + skipField = false; + jsonValue = (value as PbListBase) + .map((element) => valueToProto3Json(element, fieldInfo.type)) + .toList(); + } + } else if (fieldInfo.isMapField) { + if (context.emitDefaults || (value as Map).isNotEmpty) { + skipField = false; + jsonValue = (value as PbMap).map((key, entryValue) { + var mapEntryInfo = fieldInfo as MapFieldInfo; + return MapEntry(convertToMapKey(key, mapEntryInfo.keyFieldType!), + valueToProto3Json(entryValue, mapEntryInfo.valueFieldType)); + }); + } + } else if (_isBytes(fieldInfo.type)) { + if (context.emitDefaults || (value as List).isNotEmpty) { + skipField = false; + if ((value as List).isEmpty) { + jsonValue = null; + } else { + jsonValue = valueToProto3Json(value, fieldInfo.type); + } + } + } else if (fieldInfo.isEnum) { + // For enums, the default value is the first value listed in the enum's type definition + final defaultEnum = fieldInfo.enumValues!.first; + if (context.emitDefaults || + (value as ProtobufEnum).name != defaultEnum.name) { + skipField = false; + jsonValue = valueToProto3Json(value, fieldInfo.type); + } + } else if (fieldInfo.isGroupOrMessage) { + final originalValue = fs._values[fieldInfo.index!]; + if (context.emitDefaults || originalValue != null) { + skipField = false; + if (originalValue == null) { + jsonValue = null; + } else { + jsonValue = valueToProto3Json(value, fieldInfo.type); + } + } + } else if (PbFieldType._baseType(fieldInfo.type) == + PbFieldType._STRING_BIT) { + if (context.emitDefaults || (value as String).isNotEmpty) { + skipField = false; + jsonValue = valueToProto3Json(value, fieldInfo.type); + } + } else if (PbFieldType._baseType(fieldInfo.type) == PbFieldType._BOOL_BIT) { + if (context.emitDefaults || value != false) { + skipField = false; + jsonValue = valueToProto3Json(value, fieldInfo.type); + } + } else if (value is Int64) { + if (context.emitDefaults || !value.isZero) { + skipField = false; + jsonValue = valueToProto3Json(value, fieldInfo.type); + } + } else if (value is int) { + if (context.emitDefaults || value != 0) { + skipField = false; + jsonValue = valueToProto3Json(value, fieldInfo.type); + } + } else if (value is double) { + if (context.emitDefaults || value != 0.0) { + skipField = false; + jsonValue = valueToProto3Json(value, fieldInfo.type); + } } else { - jsonValue = valueToProto3Json(value, fieldInfo.type); + throw JsonSerializationException( + 'Unexpected field type for proto3 JSON serialization ${fieldInfo.type}'); + } + + if (!skipField) { + result[fieldInfo.name] = jsonValue; } - result[fieldInfo.name] = jsonValue; } // Extensions and unknown fields are not encoded by proto3 JSON. return result; diff --git a/protobuf/test/json_test.dart b/protobuf/test/json_test.dart index 6291ad8c7..093ab8b66 100644 --- a/protobuf/test/json_test.dart +++ b/protobuf/test/json_test.dart @@ -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; @@ -16,6 +17,32 @@ void main() { ..str = 'hello' ..int32s.addAll([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 = []; + exampleAllDefaults.int32s.addAll([]); + exampleAllDefaults.mp.addAll({}); + + 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 = [46, 28]; + exampleAllSet.int32s.addAll([3, 4, 6]); + exampleAllSet.mp.addAll({'k1': 'v2'}); + test('testProto3JsonEnum', () { // No enum value specified. expect(example.hasEnm, isFalse); @@ -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('{"val":42}'), isTrue); + final Map m = jsonDecode(json); + expect(m.length, 1); + }); + + test('testToProto3JsonFieldsSetToDefaults', () { + final json = jsonEncode(exampleAllDefaults.toProto3Json()); + expect(json.contains('"child":{"val":42}'), 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) { diff --git a/protobuf/test/map_mixin_test.dart b/protobuf/test/map_mixin_test.dart index 2e40c5bcb..22a027d53 100644 --- a/protobuf/test/map_mixin_test.dart +++ b/protobuf/test/map_mixin_test.dart @@ -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'], ''); @@ -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(), '[]'); diff --git a/protobuf/test/mock_util.dart b/protobuf/test/mock_util.dart index f586f2d0d..b7cd92ca0 100644 --- a/protobuf/test/mock_util.dart +++ b/protobuf/test/mock_util.dart @@ -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(10, 'byts', PbFieldType.OY) + ..m(11, 'mp', + keyFieldType: PbFieldType.OS, valueFieldType: PbFieldType.OS); } /// A minimal protobuf implementation for testing. @@ -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 get byts => $_get(8, []); + set byts(x) => setField(10, x); + + Map get mp => $_getMap(9); @override GeneratedMessage clone() { diff --git a/protoc_plugin/test/proto3_json_test.dart b/protoc_plugin/test/proto3_json_test.dart index 02fb71ae2..6c2fb2d0a 100644 --- a/protoc_plugin/test/proto3_json_test.dart +++ b/protoc_plugin/test/proto3_json_test.dart @@ -107,14 +107,26 @@ final testAllTypesJson = { 'defaultCord': '425' }; +// these fields have default values set for them within {@code setAllFields} and +// thus will not be serialized to JSON adhering to the proto3 specification +final keysWithDefaultValues = [ + 'defaultBool', + 'defaultNestedEnum', + 'defaultForeignEnum', + 'defaultImportEnum' +]; + +final testAllTypesJsonNoDefaults = Map.from(testAllTypesJson) + ..removeWhere((k, v) => (keysWithDefaultValues.contains(k))); + void main() { group('encode', () { test('testOutput', () { - expect(getAllSet().toProto3Json(), testAllTypesJson); + expect(getAllSet().toProto3Json(), testAllTypesJsonNoDefaults); }); test('testFrozenOutput', () { - expect(getAllSet().freeze().toProto3Json(), testAllTypesJson); + expect(getAllSet().freeze().toProto3Json(), testAllTypesJsonNoDefaults); }); test('testUnsignedOutput', () { @@ -125,26 +137,51 @@ void main() { expect(message.toProto3Json(), { 'optionalUint64': '17293822573397606400', - 'optionalFixed64': '-1152921500311945215' + 'optionalFixed64': '-1152921500311945215', + 'defaultInt32': 41, + 'defaultInt64': '42', + 'defaultUint32': 43, + 'defaultUint64': '44', + 'defaultSint32': -45, + 'defaultSint64': '46', + 'defaultFixed32': 47, + 'defaultFixed64': '48', + 'defaultSfixed32': 49, + 'defaultSfixed64': '-50', + 'defaultFloat': 51.5, + 'defaultDouble': 52000.0, + 'defaultBool': true, + 'defaultString': 'hello', + 'defaultBytes': 'd29ybGQ=', + 'defaultNestedEnum': 'BAR', + 'defaultForeignEnum': 'FOREIGN_BAR', + 'defaultImportEnum': 'IMPORT_BAR', + 'defaultStringPiece': 'abc', + 'defaultCord': '123' }); }); test('doubles', () { - void testValue(double value, Object expected) { + void testValue(double value, bool expectedInJson, Object expected) { var message = TestAllTypes() ..defaultFloat = value ..defaultDouble = value; - expect( - (message.toProto3Json() as Map)['defaultDouble'], equals(expected)); + + if (expectedInJson) { + expect((message.toProto3Json() as Map)['defaultDouble'], + equals(expected)); + } else { + expect((message.toProto3Json() as Map)['defaultDouble'], isNull); + } } - testValue(-0.0, -0.0); - testValue(0.0, 0); - testValue(1.0, 1); - testValue(-1.0, -1); - testValue(double.nan, 'NaN'); - testValue(double.infinity, 'Infinity'); - testValue(double.negativeInfinity, '-Infinity'); + testValue(-0.0, false, 0); + testValue(0.0, false, 0); + testValue(1.0, true, 1); + testValue(-1.0, true, -1); + testValue(double.nan, true, 'NaN'); + testValue(double.infinity, true, 'Infinity'); + testValue(double.negativeInfinity, true, '-Infinity'); }); test('map value', () { @@ -255,7 +292,27 @@ void main() { .toProto3Json(typeRegistry: TypeRegistry([TestAllTypes()])), { '@type': 'type.googleapis.com/protobuf_unittest.TestAllTypes', - 'optionalFixed64': '100' + 'optionalFixed64': '100', + 'defaultInt32': 41, + 'defaultInt64': '42', + 'defaultUint32': 43, + 'defaultUint64': '44', + 'defaultSint32': -45, + 'defaultSint64': '46', + 'defaultFixed32': 47, + 'defaultFixed64': '48', + 'defaultSfixed32': 49, + 'defaultSfixed64': '-50', + 'defaultFloat': 51.5, + 'defaultDouble': 52000.0, + 'defaultBool': true, + 'defaultString': 'hello', + 'defaultBytes': 'd29ybGQ=', + 'defaultNestedEnum': 'BAR', + 'defaultForeignEnum': 'FOREIGN_BAR', + 'defaultImportEnum': 'IMPORT_BAR', + 'defaultStringPiece': 'abc', + 'defaultCord': '123', }); expect( Any.pack(Timestamp.fromDateTime(DateTime.utc(1969, 7, 20, 20, 17)))