diff --git a/Sources/SwiftProtobuf/BinaryEncodingOptions.swift b/Sources/SwiftProtobuf/BinaryEncodingOptions.swift new file mode 100644 index 000000000..811a537c3 --- /dev/null +++ b/Sources/SwiftProtobuf/BinaryEncodingOptions.swift @@ -0,0 +1,32 @@ +// Sources/SwiftProtobuf/BinaryEncodingOptions.swift - Binary encoding options +// +// Copyright (c) 2014 - 2023 Apple Inc. and the project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See LICENSE.txt for license information: +// https://github.com/apple/swift-protobuf/blob/main/LICENSE.txt +// +// ----------------------------------------------------------------------------- +/// +/// Binary encoding options +/// +// ----------------------------------------------------------------------------- + +/// Options for binary encoding. +public struct BinaryEncodingOptions { + /// 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 + /// 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() {} +} diff --git a/Sources/SwiftProtobuf/BinaryEncodingVisitor.swift b/Sources/SwiftProtobuf/BinaryEncodingVisitor.swift index 748de2538..597bbb199 100644 --- a/Sources/SwiftProtobuf/BinaryEncodingVisitor.swift +++ b/Sources/SwiftProtobuf/BinaryEncodingVisitor.swift @@ -17,6 +17,7 @@ import Foundation /// Visitor that encodes a message graph in the protobuf binary wire format. internal struct BinaryEncodingVisitor: Visitor { + private let options: BinaryEncodingOptions var encoder: BinaryEncoder @@ -26,12 +27,14 @@ internal struct BinaryEncodingVisitor: Visitor { /// - Precondition: `pointer` must point to an allocated block of memory that /// is large enough to hold the entire encoded message. For performance /// reasons, the encoder does not make any attempts to verify this. - init(forWritingInto pointer: UnsafeMutableRawPointer) { - encoder = BinaryEncoder(forWritingInto: pointer) + init(forWritingInto pointer: UnsafeMutableRawPointer, options: BinaryEncodingOptions) { + self.encoder = BinaryEncoder(forWritingInto: pointer) + self.options = options } - init(encoder: BinaryEncoder) { + init(encoder: BinaryEncoder, options: BinaryEncodingOptions) { self.encoder = encoder + self.options = options } mutating func visitUnknown(bytes: Data) throws { @@ -262,16 +265,16 @@ internal struct BinaryEncodingVisitor: Visitor { value: _ProtobufMap.BaseType, fieldNumber: Int ) throws { - for (k,v) in value { - encoder.startField(fieldNumber: fieldNumber, wireFormat: .lengthDelimited) - var sizer = BinaryEncodingSizeVisitor() - try KeyType.visitSingular(value: k, fieldNumber: 1, with: &sizer) - try ValueType.visitSingular(value: v, fieldNumber: 2, with: &sizer) - let entrySize = sizer.serializedSize - encoder.putVarInt(value: entrySize) - try KeyType.visitSingular(value: k, fieldNumber: 1, with: &self) - try ValueType.visitSingular(value: v, fieldNumber: 2, with: &self) - } + try iterateAndEncode( + map: value, fieldNumber: fieldNumber, isOrderedBefore: KeyType._lessThan, + encodeWithSizer: { sizer, key, value in + try KeyType.visitSingular(value: key, fieldNumber: 1, with: &sizer) + try ValueType.visitSingular(value: value, fieldNumber: 2, with: &sizer) + }, encodeWithVisitor: { visitor, key, value in + try KeyType.visitSingular(value: key, fieldNumber: 1, with: &visitor) + try ValueType.visitSingular(value: value, fieldNumber: 2, with: &visitor) + } + ) } mutating func visitMapField( @@ -279,16 +282,16 @@ internal struct BinaryEncodingVisitor: Visitor { value: _ProtobufEnumMap.BaseType, fieldNumber: Int ) throws where ValueType.RawValue == Int { - for (k,v) in value { - encoder.startField(fieldNumber: fieldNumber, wireFormat: .lengthDelimited) - var sizer = BinaryEncodingSizeVisitor() - try KeyType.visitSingular(value: k, fieldNumber: 1, with: &sizer) - try sizer.visitSingularEnumField(value: v, fieldNumber: 2) - let entrySize = sizer.serializedSize - encoder.putVarInt(value: entrySize) - try KeyType.visitSingular(value: k, fieldNumber: 1, with: &self) - try visitSingularEnumField(value: v, fieldNumber: 2) - } + try iterateAndEncode( + map: value, fieldNumber: fieldNumber, isOrderedBefore: KeyType._lessThan, + encodeWithSizer: { sizer, key, value in + try KeyType.visitSingular(value: key, fieldNumber: 1, with: &sizer) + try sizer.visitSingularEnumField(value: value, fieldNumber: 2) + }, encodeWithVisitor: { visitor, key, value in + try KeyType.visitSingular(value: key, fieldNumber: 1, with: &visitor) + try visitor.visitSingularEnumField(value: value, fieldNumber: 2) + } + ) } mutating func visitMapField( @@ -296,15 +299,45 @@ internal struct BinaryEncodingVisitor: Visitor { value: _ProtobufMessageMap.BaseType, fieldNumber: Int ) throws { - for (k,v) in value { - encoder.startField(fieldNumber: fieldNumber, wireFormat: .lengthDelimited) - var sizer = BinaryEncodingSizeVisitor() - try KeyType.visitSingular(value: k, fieldNumber: 1, with: &sizer) - try sizer.visitSingularMessageField(value: v, fieldNumber: 2) - let entrySize = sizer.serializedSize - encoder.putVarInt(value: entrySize) - try KeyType.visitSingular(value: k, fieldNumber: 1, with: &self) - try visitSingularMessageField(value: v, fieldNumber: 2) + try iterateAndEncode( + map: value, fieldNumber: fieldNumber, isOrderedBefore: KeyType._lessThan, + encodeWithSizer: { sizer, key, value in + try KeyType.visitSingular(value: key, fieldNumber: 1, with: &sizer) + try sizer.visitSingularMessageField(value: value, fieldNumber: 2) + }, encodeWithVisitor: { visitor, key, value 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( + map: Dictionary, + fieldNumber: Int, + isOrderedBefore: (K, K) -> Bool, + encodeWithSizer: (inout BinaryEncodingSizeVisitor, K, V) throws -> (), + encodeWithVisitor: (inout BinaryEncodingVisitor, K, V) throws -> () + ) throws { + if options.useDeterministicOrdering { + for (k,v) in map.sorted(by: { isOrderedBefore( $0.0, $1.0) }) { + encoder.startField(fieldNumber: fieldNumber, wireFormat: .lengthDelimited) + var sizer = BinaryEncodingSizeVisitor() + try encodeWithSizer(&sizer, k, v) + let entrySize = sizer.serializedSize + encoder.putVarInt(value: entrySize) + try encodeWithVisitor(&self, k, v) + } + } else { + for (k,v) in map { + encoder.startField(fieldNumber: fieldNumber, wireFormat: .lengthDelimited) + var sizer = BinaryEncodingSizeVisitor() + try encodeWithSizer(&sizer, k, v) + let entrySize = sizer.serializedSize + encoder.putVarInt(value: entrySize) + try encodeWithVisitor(&self, k, v) + } } } @@ -313,7 +346,7 @@ internal struct BinaryEncodingVisitor: Visitor { start: Int, end: Int ) throws { - var subVisitor = BinaryEncodingMessageSetVisitor(encoder: encoder) + var subVisitor = BinaryEncodingMessageSetVisitor(encoder: encoder, options: options) try fields.traverse(visitor: &subVisitor, start: start, end: end) encoder = subVisitor.encoder } @@ -323,9 +356,12 @@ extension BinaryEncodingVisitor { // Helper Visitor to when writing out the extensions as MessageSets. internal struct BinaryEncodingMessageSetVisitor: SelectiveVisitor { + private let options: BinaryEncodingOptions + var encoder: BinaryEncoder - init(encoder: BinaryEncoder) { + init(encoder: BinaryEncoder, options: BinaryEncodingOptions) { + self.options = options self.encoder = encoder } @@ -342,7 +378,7 @@ extension BinaryEncodingVisitor { let length = try value.serializedDataSize() encoder.putVarInt(value: length) // Create the sub encoder after writing the length. - var subVisitor = BinaryEncodingVisitor(encoder: encoder) + var subVisitor = BinaryEncodingVisitor(encoder: encoder, options: options) try value.traverse(visitor: &subVisitor) encoder = subVisitor.encoder @@ -351,5 +387,4 @@ extension BinaryEncodingVisitor { // SelectiveVisitor handles the rest. } - } diff --git a/Sources/SwiftProtobuf/JSONEncodingOptions.swift b/Sources/SwiftProtobuf/JSONEncodingOptions.swift index 95419096d..522b1a9b0 100644 --- a/Sources/SwiftProtobuf/JSONEncodingOptions.swift +++ b/Sources/SwiftProtobuf/JSONEncodingOptions.swift @@ -22,5 +22,19 @@ public struct JSONEncodingOptions { /// 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 + /// 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() {} } diff --git a/Sources/SwiftProtobuf/JSONEncodingVisitor.swift b/Sources/SwiftProtobuf/JSONEncodingVisitor.swift index b250314e2..3f0e912e0 100644 --- a/Sources/SwiftProtobuf/JSONEncodingVisitor.swift +++ b/Sources/SwiftProtobuf/JSONEncodingVisitor.swift @@ -340,39 +340,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(fieldType: _ProtobufMap.Type, value: _ProtobufMap.BaseType, fieldNumber: Int) throws { - try startField(for: fieldNumber) - encoder.append(text: "{") - var mapVisitor = JSONMapEncodingVisitor(encoder: encoder, 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 = mapVisitor.encoder - encoder.append(text: "}") } mutating func visitMapField(fieldType: _ProtobufEnumMap.Type, value: _ProtobufEnumMap.BaseType, fieldNumber: Int) throws where ValueType.RawValue == Int { - try startField(for: fieldNumber) - encoder.append(text: "{") - var mapVisitor = JSONMapEncodingVisitor(encoder: encoder, 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 = mapVisitor.encoder - encoder.append(text: "}") } mutating func visitMapField(fieldType: _ProtobufMessageMap.Type, value: _ProtobufMessageMap.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( + map: Dictionary, + fieldNumber: Int, + isOrderedBefore: (K, K) -> Bool, + encode: (inout JSONMapEncodingVisitor, K, V) throws -> () + ) throws { try startField(for: fieldNumber) encoder.append(text: "{") var mapVisitor = JSONMapEncodingVisitor(encoder: encoder, 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 = mapVisitor.encoder encoder.append(text: "}") diff --git a/Sources/SwiftProtobuf/Message+BinaryAdditions.swift b/Sources/SwiftProtobuf/Message+BinaryAdditions.swift index faf06ff59..c0a480693 100644 --- a/Sources/SwiftProtobuf/Message+BinaryAdditions.swift +++ b/Sources/SwiftProtobuf/Message+BinaryAdditions.swift @@ -22,15 +22,37 @@ extension Message { /// - Parameters: /// - partial: If `false` (the default), this method will check /// `Message.isInitialized` before encoding to verify that all required - /// fields are present. If any are missing, this method throws + /// fields are present. If any are missing, this method throws. /// `BinaryEncodingError.missingRequiredFields`. /// - Returns: A `Data` value containing the binary serialization of the /// message. /// - Throws: `BinaryEncodingError` if encoding fails. public func serializedData(partial: Bool = false) throws -> Data { + return try serializedData(partial: partial, options: BinaryEncodingOptions()) + } + + /// Returns a `Data` value containing the Protocol Buffer binary format + /// serialization of the message. + /// + /// - Parameters: + /// - partial: If `false` (the default), this method will check + /// `Message.isInitialized` before encoding to verify that all required + /// fields are present. If any are missing, this method throws. + /// `BinaryEncodingError.missingRequiredFields`. + /// - options: The `BinaryEncodingOptions` to use. + /// - Returns: A `SwiftProtobufContiguousBytes` instance containing the binary serialization + /// of the message. + /// + /// - Throws: `BinaryEncodingError` if encoding fails. + public func serializedData( + partial: Bool = false, + options: BinaryEncodingOptions + ) throws -> Data { if !partial && !isInitialized { throw BinaryEncodingError.missingRequiredFields } + + // Note that this assumes `options` will not change the required size. let requiredSize = try serializedDataSize() // Messages have a 2GB limit in encoded size, the upstread C++ code @@ -48,7 +70,7 @@ extension Message { var data = Data(count: requiredSize) try data.withUnsafeMutableBytes { (body: UnsafeMutableRawBufferPointer) in if let baseAddress = body.baseAddress, body.count > 0 { - var visitor = BinaryEncodingVisitor(forWritingInto: baseAddress) + var visitor = BinaryEncodingVisitor(forWritingInto: baseAddress, options: options) try traverse(visitor: &visitor) // Currently not exposing this from the api because it really would be // an internal error in the library and should never happen. diff --git a/Sources/SwiftProtobuf/TextFormatEncodingVisitor.swift b/Sources/SwiftProtobuf/TextFormatEncodingVisitor.swift index af60e36d6..bd6ac6aa9 100644 --- a/Sources/SwiftProtobuf/TextFormatEncodingVisitor.swift +++ b/Sources/SwiftProtobuf/TextFormatEncodingVisitor.swift @@ -504,16 +504,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( - value: [T], fieldNumber: Int, + private mutating func iterateAndEncode( + 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() } @@ -525,42 +525,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) } @@ -591,14 +591,14 @@ 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(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) } @@ -606,17 +606,17 @@ internal struct TextFormatEncodingVisitor: Visitor { /// Helper to encapsulate the common structure of iterating over a map /// and encoding the keys and values. - private mutating func _visitMap( + private mutating func iterateAndEncode( map: Dictionary, fieldNumber: Int, isOrderedBefore: (K, K) -> Bool, - coder: (inout TextFormatEncodingVisitor, K, V) throws -> () + encode: (inout TextFormatEncodingVisitor, K, V) throws -> () ) throws { for (k,v) in map.sorted(by: { isOrderedBefore( $0.0, $1.0) }) { emitFieldName(lookingUp: fieldNumber) encoder.startMessageField() var visitor = TextFormatEncodingVisitor(nameMap: nil, nameResolver: mapNameResolver, extensions: nil, encoder: encoder, options: options) - try coder(&visitor, k, v) + try encode(&visitor, k, v) encoder = visitor.encoder encoder.endMessageField() } @@ -627,7 +627,7 @@ internal struct TextFormatEncodingVisitor: Visitor { value: _ProtobufMap.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) @@ -639,7 +639,7 @@ internal struct TextFormatEncodingVisitor: Visitor { value: _ProtobufEnumMap.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) @@ -651,7 +651,7 @@ internal struct TextFormatEncodingVisitor: Visitor { value: _ProtobufMessageMap.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) diff --git a/Tests/SwiftProtobufTests/Test_BinaryEncodingOptions.swift b/Tests/SwiftProtobufTests/Test_BinaryEncodingOptions.swift new file mode 100644 index 000000000..96238bbcf --- /dev/null +++ b/Tests/SwiftProtobufTests/Test_BinaryEncodingOptions.swift @@ -0,0 +1,74 @@ +// Tests/SwiftProtobufTests/Test_BinaryEncodingOptions.swift - Tests for binary encoding options +// +// Copyright (c) 2014 - 2023 Apple Inc. and the project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See LICENSE.txt for license information: +// https://github.com/apple/swift-protobuf/blob/main/LICENSE.txt +// +// ----------------------------------------------------------------------------- +/// +/// Test for the use of BinaryEncodingOptions +/// +// ----------------------------------------------------------------------------- + +import Foundation +import XCTest +import SwiftProtobuf + +class Test_BinaryEncodingOptions: XCTestCase { + + func testUseDeterministicOrdering() throws { + var options = BinaryEncodingOptions() + options.useDeterministicOrdering = true + + let message1 = ProtobufUnittest_Message3.with { + $0.mapStringString = [ + "b": "B", + "a": "A", + "0": "0", + "UPPER": "v", + "x": "X", + ] + $0.mapInt32Message = [ + 5: .with { $0.optionalSint32 = 5 }, + 1: .with { $0.optionalSint32 = 1 }, + 3: .with { $0.optionalSint32 = 3 }, + ] + $0.mapInt32Enum = [ + 5: .foo, + 3: .bar, + 0: .baz, + 1: .extra3, + ] + } + + let message2 = ProtobufUnittest_Message3.with { + $0.mapStringString = [ + "UPPER": "v", + "a": "A", + "b": "B", + "x": "X", + "0": "0", + ] + $0.mapInt32Message = [ + 1: .with { $0.optionalSint32 = 1 }, + 3: .with { $0.optionalSint32 = 3 }, + 5: .with { $0.optionalSint32 = 5 }, + ] + $0.mapInt32Enum = [ + 3: .bar, + 5: .foo, + 1: .extra3, + 0: .baz, + ] + } + + // Approximation that serializing models with the same data (but initialized with keys in + // different orders) consistently produces the same outputs. + let expectedOutput = try message1.serializedData(options: options) + for _ in 0..<10 { + XCTAssertEqual(try message2.serializedData(options: options), expectedOutput) + } + } +} diff --git a/Tests/SwiftProtobufTests/Test_JSONEncodingOptions.swift b/Tests/SwiftProtobufTests/Test_JSONEncodingOptions.swift index d497469ec..c9d7b8252 100644 --- a/Tests/SwiftProtobufTests/Test_JSONEncodingOptions.swift +++ b/Tests/SwiftProtobufTests/Test_JSONEncodingOptions.swift @@ -181,4 +181,48 @@ class Test_JSONEncodingOptions: XCTestCase { XCTAssertEqual(try msg7.jsonString(options: protoNames), "{\"@type\":\"type.googleapis.com/protobuf_unittest.TestAllTypes\",\"optional_nested_enum\":\"NEG\"}") } + + func testUseDeterministicOrdering() { + var options = JSONEncodingOptions() + options.useDeterministicOrdering = true + + let stringMap = ProtobufUnittest_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 = ProtobufUnittest_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 = ProtobufUnittest_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\"}}" + ) + } }