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

Add useDeterministicOrdering to JSONEncodingOptions #1478

Merged
merged 11 commits into from
Oct 26, 2023
14 changes: 14 additions & 0 deletions Sources/SwiftProtobuf/JSONEncodingOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,19 @@ public struct JSONEncodingOptions: Sendable {
/// By default they are converted to JSON(lowerCamelCase) names.
public var preserveProtoFieldNames: Bool = false

/// Whether to use deterministic ordering when serializing.
///
/// Note that the deterministic serialization is NOT canonical across languages.
/// It is not guaranteed to remain stable over time. It is unstable across
rebello95 marked this conversation as resolved.
Show resolved Hide resolved
/// different builds with schema changes due to unknown fields. Users who need
/// canonical serialization (e.g., persistent storage in a canonical form,
/// fingerprinting, etc.) should define their own canonicalization specification
/// and implement their own serializer rather than relying on this API.
///
/// If deterministic serialization is requested, map entries will be sorted
/// by keys in lexographical order. This is an implementation detail
/// and subject to change.
public var useDeterministicOrdering: Bool = false

public init() {}
}
52 changes: 31 additions & 21 deletions Sources/SwiftProtobuf/JSONEncodingVisitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -358,39 +358,49 @@ internal struct JSONEncodingVisitor: Visitor {
// Packed fields are handled the same as non-packed fields, so JSON just
// relies on the default implementations in Visitor.swift



mutating func visitMapField<KeyType, ValueType: MapValueType>(fieldType: _ProtobufMap<KeyType, ValueType>.Type, value: _ProtobufMap<KeyType, ValueType>.BaseType, fieldNumber: Int) throws {
try startField(for: fieldNumber)
encoder.append(text: "{")
var mapVisitor = JSONMapEncodingVisitor(encoder: JSONEncoder(), options: options)
for (k,v) in value {
try KeyType.visitSingular(value: k, fieldNumber: 1, with: &mapVisitor)
try ValueType.visitSingular(value: v, fieldNumber: 2, with: &mapVisitor)
try iterateAndEncode(map: value, fieldNumber: fieldNumber, isOrderedBefore: KeyType._lessThan) {
(visitor: inout JSONMapEncodingVisitor, key, value) throws -> () in
try KeyType.visitSingular(value: key, fieldNumber: 1, with: &visitor)
try ValueType.visitSingular(value: value, fieldNumber: 2, with: &visitor)
}
encoder.append(utf8Bytes: mapVisitor.bytesResult)
encoder.append(text: "}")
}

mutating func visitMapField<KeyType, ValueType>(fieldType: _ProtobufEnumMap<KeyType, ValueType>.Type, value: _ProtobufEnumMap<KeyType, ValueType>.BaseType, fieldNumber: Int) throws where ValueType.RawValue == Int {
try startField(for: fieldNumber)
encoder.append(text: "{")
var mapVisitor = JSONMapEncodingVisitor(encoder: JSONEncoder(), options: options)
for (k, v) in value {
try KeyType.visitSingular(value: k, fieldNumber: 1, with: &mapVisitor)
try mapVisitor.visitSingularEnumField(value: v, fieldNumber: 2)
try iterateAndEncode(map: value, fieldNumber: fieldNumber, isOrderedBefore: KeyType._lessThan) {
(visitor: inout JSONMapEncodingVisitor, key, value) throws -> () in
try KeyType.visitSingular(value: key, fieldNumber: 1, with: &visitor)
try visitor.visitSingularEnumField(value: value, fieldNumber: 2)
}
encoder.append(utf8Bytes: mapVisitor.bytesResult)
encoder.append(text: "}")
}

mutating func visitMapField<KeyType, ValueType>(fieldType: _ProtobufMessageMap<KeyType, ValueType>.Type, value: _ProtobufMessageMap<KeyType, ValueType>.BaseType, fieldNumber: Int) throws {
try iterateAndEncode(map: value, fieldNumber: fieldNumber, isOrderedBefore: KeyType._lessThan) {
(visitor: inout JSONMapEncodingVisitor, key, value) throws -> () in
try KeyType.visitSingular(value: key, fieldNumber: 1, with: &visitor)
try visitor.visitSingularMessageField(value: value, fieldNumber: 2)
}
}

/// Helper to encapsulate the common structure of iterating over a map
/// and encoding the keys and values.
private mutating func iterateAndEncode<K, V>(
map: Dictionary<K, V>,
fieldNumber: Int,
isOrderedBefore: (K, K) -> Bool,
encode: (inout JSONMapEncodingVisitor, K, V) throws -> ()
) throws {
try startField(for: fieldNumber)
encoder.append(text: "{")
var mapVisitor = JSONMapEncodingVisitor(encoder: JSONEncoder(), options: options)
for (k,v) in value {
try KeyType.visitSingular(value: k, fieldNumber: 1, with: &mapVisitor)
try mapVisitor.visitSingularMessageField(value: v, fieldNumber: 2)
if options.useDeterministicOrdering {
for (k,v) in map.sorted(by: { isOrderedBefore( $0.0, $1.0) }) {
try encode(&mapVisitor, k, v)
}
} else {
for (k,v) in map {
try encode(&mapVisitor, k, v)
}
}
encoder.append(utf8Bytes: mapVisitor.bytesResult)
encoder.append(text: "}")
Expand Down
36 changes: 18 additions & 18 deletions Sources/SwiftProtobuf/TextFormatEncodingVisitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -503,16 +503,16 @@ internal struct TextFormatEncodingVisitor: Visitor {
// fields (including proto3's default use of packed) without
// introducing the baggage of a separate option.

private mutating func _visitPacked<T>(
value: [T], fieldNumber: Int,
private mutating func iterateAndEncode<T>(
packedValue: [T], fieldNumber: Int,
encode: (T, inout TextFormatEncoder) -> ()
) throws {
assert(!value.isEmpty)
assert(!packedValue.isEmpty)
emitFieldName(lookingUp: fieldNumber)
encoder.startRegularField()
var firstItem = true
encoder.startArray()
for v in value {
for v in packedValue {
if !firstItem {
encoder.arraySeparator()
}
Expand All @@ -524,42 +524,42 @@ internal struct TextFormatEncodingVisitor: Visitor {
}

mutating func visitPackedFloatField(value: [Float], fieldNumber: Int) throws {
try _visitPacked(value: value, fieldNumber: fieldNumber) {
try iterateAndEncode(packedValue: value, fieldNumber: fieldNumber) {
(v: Float, encoder: inout TextFormatEncoder) in
encoder.putFloatValue(value: v)
}
}

mutating func visitPackedDoubleField(value: [Double], fieldNumber: Int) throws {
try _visitPacked(value: value, fieldNumber: fieldNumber) {
try iterateAndEncode(packedValue: value, fieldNumber: fieldNumber) {
(v: Double, encoder: inout TextFormatEncoder) in
encoder.putDoubleValue(value: v)
}
}

mutating func visitPackedInt32Field(value: [Int32], fieldNumber: Int) throws {
try _visitPacked(value: value, fieldNumber: fieldNumber) {
try iterateAndEncode(packedValue: value, fieldNumber: fieldNumber) {
(v: Int32, encoder: inout TextFormatEncoder) in
encoder.putInt64(value: Int64(v))
}
}

mutating func visitPackedInt64Field(value: [Int64], fieldNumber: Int) throws {
try _visitPacked(value: value, fieldNumber: fieldNumber) {
try iterateAndEncode(packedValue: value, fieldNumber: fieldNumber) {
(v: Int64, encoder: inout TextFormatEncoder) in
encoder.putInt64(value: v)
}
}

mutating func visitPackedUInt32Field(value: [UInt32], fieldNumber: Int) throws {
try _visitPacked(value: value, fieldNumber: fieldNumber) {
try iterateAndEncode(packedValue: value, fieldNumber: fieldNumber) {
(v: UInt32, encoder: inout TextFormatEncoder) in
encoder.putUInt64(value: UInt64(v))
}
}

mutating func visitPackedUInt64Field(value: [UInt64], fieldNumber: Int) throws {
try _visitPacked(value: value, fieldNumber: fieldNumber) {
try iterateAndEncode(packedValue: value, fieldNumber: fieldNumber) {
(v: UInt64, encoder: inout TextFormatEncoder) in
encoder.putUInt64(value: v)
}
Expand Down Expand Up @@ -590,26 +590,26 @@ internal struct TextFormatEncodingVisitor: Visitor {
}

mutating func visitPackedBoolField(value: [Bool], fieldNumber: Int) throws {
try _visitPacked(value: value, fieldNumber: fieldNumber) {
try iterateAndEncode(packedValue: value, fieldNumber: fieldNumber) {
(v: Bool, encoder: inout TextFormatEncoder) in
encoder.putBoolValue(value: v)
}
}

mutating func visitPackedEnumField<E: Enum>(value: [E], fieldNumber: Int) throws {
try _visitPacked(value: value, fieldNumber: fieldNumber) {
try iterateAndEncode(packedValue: value, fieldNumber: fieldNumber) {
(v: E, encoder: inout TextFormatEncoder) in
encoder.putEnumValue(value: v)
}
}

/// Helper to encapsulate the common structure of iterating over a map
/// and encoding the keys and values.
private mutating func _visitMap<K, V>(
private mutating func iterateAndEncode<K, V>(
thomasvl marked this conversation as resolved.
Show resolved Hide resolved
map: Dictionary<K, V>,
fieldNumber: Int,
isOrderedBefore: (K, K) -> Bool,
coder: (inout TextFormatEncodingVisitor, K, V) throws -> ()
encode: (inout TextFormatEncodingVisitor, K, V) throws -> ()
thomasvl marked this conversation as resolved.
Show resolved Hide resolved
) throws {
// Cache old visitor configuration
let oldNameMap = self.nameMap
Expand All @@ -625,7 +625,7 @@ internal struct TextFormatEncodingVisitor: Visitor {
self.nameResolver = mapNameResolver
self.extensions = nil

try coder(&self, k, v)
try encode(&self, k, v)

// Restore configuration before resuming containing message
self.extensions = oldExtensions
Expand All @@ -641,7 +641,7 @@ internal struct TextFormatEncodingVisitor: Visitor {
value: _ProtobufMap<KeyType, ValueType>.BaseType,
fieldNumber: Int
) throws {
try _visitMap(map: value, fieldNumber: fieldNumber, isOrderedBefore: KeyType._lessThan) {
try iterateAndEncode(map: value, fieldNumber: fieldNumber, isOrderedBefore: KeyType._lessThan) {
(visitor: inout TextFormatEncodingVisitor, key, value) throws -> () in
try KeyType.visitSingular(value: key, fieldNumber: 1, with: &visitor)
try ValueType.visitSingular(value: value, fieldNumber: 2, with: &visitor)
Expand All @@ -653,7 +653,7 @@ internal struct TextFormatEncodingVisitor: Visitor {
value: _ProtobufEnumMap<KeyType, ValueType>.BaseType,
fieldNumber: Int
) throws where ValueType.RawValue == Int {
try _visitMap(map: value, fieldNumber: fieldNumber, isOrderedBefore: KeyType._lessThan) {
try iterateAndEncode(map: value, fieldNumber: fieldNumber, isOrderedBefore: KeyType._lessThan) {
(visitor: inout TextFormatEncodingVisitor, key, value) throws -> () in
try KeyType.visitSingular(value: key, fieldNumber: 1, with: &visitor)
try visitor.visitSingularEnumField(value: value, fieldNumber: 2)
Expand All @@ -665,7 +665,7 @@ internal struct TextFormatEncodingVisitor: Visitor {
value: _ProtobufMessageMap<KeyType, ValueType>.BaseType,
fieldNumber: Int
) throws {
try _visitMap(map: value, fieldNumber: fieldNumber, isOrderedBefore: KeyType._lessThan) {
try iterateAndEncode(map: value, fieldNumber: fieldNumber, isOrderedBefore: KeyType._lessThan) {
(visitor: inout TextFormatEncodingVisitor, key, value) throws -> () in
try KeyType.visitSingular(value: key, fieldNumber: 1, with: &visitor)
try visitor.visitSingularMessageField(value: value, fieldNumber: 2)
Expand Down
44 changes: 44 additions & 0 deletions Tests/SwiftProtobufTests/Test_JSONEncodingOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -272,4 +272,48 @@ class Test_JSONEncodingOptions: XCTestCase {
XCTAssertEqual(try msg7.jsonString(options: protoNames),
"{\"@type\":\"type.googleapis.com/swift_proto_testing.TestAllTypes\",\"optional_nested_enum\":\"NEG\"}")
}

func testUseDeterministicOrdering() {
var options = JSONEncodingOptions()
options.useDeterministicOrdering = true

let stringMap = SwiftProtoTesting_Message3.with {
$0.mapStringString = [
"b": "B",
"a": "A",
"0": "0",
"UPPER": "v",
"x": "X",
]
}
XCTAssertEqual(
try stringMap.jsonString(options: options),
"{\"mapStringString\":{\"0\":\"0\",\"UPPER\":\"v\",\"a\":\"A\",\"b\":\"B\",\"x\":\"X\"}}"
)

let messageMap = SwiftProtoTesting_Message3.with {
$0.mapInt32Message = [
5: .with { $0.optionalSint32 = 5 },
1: .with { $0.optionalSint32 = 1 },
3: .with { $0.optionalSint32 = 3 },
]
}
XCTAssertEqual(
try messageMap.jsonString(options: options),
"{\"mapInt32Message\":{\"1\":{\"optionalSint32\":1},\"3\":{\"optionalSint32\":3},\"5\":{\"optionalSint32\":5}}}"
)

let enumMap = SwiftProtoTesting_Message3.with {
$0.mapInt32Enum = [
5: .foo,
3: .bar,
0: .baz,
1: .extra3,
]
}
XCTAssertEqual(
try enumMap.jsonString(options: options),
"{\"mapInt32Enum\":{\"0\":\"BAZ\",\"1\":\"EXTRA_3\",\"3\":\"BAR\",\"5\":\"FOO\"}}"
)
}
}