From cdfbbab48e82d38115804e286405b9834f031a0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Quenaudon?= Date: Thu, 9 Jul 2026 10:45:41 +0100 Subject: [PATCH] Finishing and cleaning up FieldMask support --- CHANGELOG.md | 30 +++ .../squareup/wire/WireTypeAdapterFactory.kt | 8 +- .../squareup/wire/WireJsonAdapterFactory.kt | 8 +- .../squareup/wire/FieldMaskRoundTripTest.kt | 151 +++++++++++++ .../src/main/swift/FieldMask.swift | 10 - .../main/swift/ProtoCodable/ProtoReader.swift | 17 ++ .../src/test/swift/ProtoDecoderTests.swift | 22 ++ .../kotlin/com/squareup/wire/AnyMessage.kt | 2 +- .../wire/internal/RuntimeMessageAdapter.kt | 28 ++- .../kotlin/com/squareup/wire/FieldMaskTest.kt | 7 + .../wire/internal/FieldMaskJsonFormatter.kt | 8 +- .../google/protobuf/field_mask.proto | 200 +++++++++++++++++- .../wire/schema/DynamicSerializationTest.kt | 25 +++ .../com/squareup/wire/swift/SwiftGenerator.kt | 4 +- .../squareup/wire/swift/SwiftGeneratorTest.kt | 38 ++++ .../src/main/swift/ContainsDuration.swift | 2 +- .../src/main/swift/ContainsTimestamp.swift | 2 +- .../SwiftModuleOneAmbiguousMessage.swift | 4 +- .../manifest/module_three/DateTime.swift | 2 +- .../module_two/SwiftModuleTwoMessage.swift | 2 +- .../no-manifest/src/main/swift/AllTypes.swift | 4 +- .../no-manifest/src/main/swift/FooBar.swift | 2 +- .../src/main/swift/OuterMessage.swift | 2 +- .../src/main/swift/VersionOne.swift | 2 +- .../src/main/swift/VersionTwo.swift | 2 +- .../proto/proto3/contains_field_mask.proto | 1 + .../com/squareup/wire/WireJsonTest.kt | 51 ++++- 27 files changed, 599 insertions(+), 35 deletions(-) create mode 100644 wire-protoc-compatibility-tests/src/test/java/com/squareup/wire/FieldMaskRoundTripTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index a5b2eac2e8..add242f5ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ Change Log Unreleased ---------- +### Common + +* New: Support `google.protobuf.FieldMask` (#3644, #3645, #3646, #3647, #3648, #3649, #3650, #3651, + #3652). Fields of this type map to the new `com.squareup.wire.FieldMask` class and are encoded + with `ProtoAdapter.FIELD_MASK`. JSON uses the spec's string encoding + (`"user.displayName,photo"`), including when a field mask is packed in a `google.protobuf.Any`. + `ProtoAdapter.FIELD_MASK` is registered by default in `WireJsonAdapterFactory` and + `WireTypeAdapterFactory` for use with `AnyMessage`. If your build previously supplied its own + `google/protobuf/field_mask.proto`, Wire's bundled copy now takes precedence: the `FieldMask` + message class is no longer generated, fields reference `com.squareup.wire.FieldMask` instead, + `Schema.protoAdapter(...)` decodes such fields to `FieldMask` instances instead of maps, and + JSON switches from an object to the specification's string encoding. +* New: `AnyMessage.pack(adapter, value)` packs values whose adapters aren't generated message + adapters, such as `ProtoAdapter.FIELD_MASK` (#3648). +* Behavior change: `ProtoAdapter.newMessageAdapter(Class)`, `Schema.protoAdapter(...)`, and the + moshi/gson runtime adapters now merge duplicated occurrences of a singular message field per the + protobuf specification, instead of keeping only the last occurrence (#3652). This includes fields + of well-known types that map to platform types, such as `google.protobuf.Duration` and the + wrapper types. Generated Java/Kotlin code already behaved this way. + +### Swift + +* New: Support `google.protobuf.FieldMask` via the new `FieldMask` struct, with proto and Codable + (JSON) encodings (#3645, #3646, #3652). `AnyMessage` values whose type URL is + `type.googleapis.com/google.protobuf.FieldMask` now use the specification's string encoding for + their JSON `value` instead of base64 data. +* Behavior change: generated code now merges duplicated occurrences of a singular message field + per the protobuf specification, instead of keeping only the last occurrence. This matches + generated Java/Kotlin code. The new `ProtoReader.decode(_:mergingInto:)` implements the merge. + Version 7.0.0-alpha04 --------------------- diff --git a/wire-gson-support/src/main/java/com/squareup/wire/WireTypeAdapterFactory.kt b/wire-gson-support/src/main/java/com/squareup/wire/WireTypeAdapterFactory.kt index 6d4f2ecea7..113239620c 100644 --- a/wire-gson-support/src/main/java/com/squareup/wire/WireTypeAdapterFactory.kt +++ b/wire-gson-support/src/main/java/com/squareup/wire/WireTypeAdapterFactory.kt @@ -52,10 +52,16 @@ import com.squareup.wire.internal.createRuntimeMessageAdapter * precedence over the `jsonName` option. */ class WireTypeAdapterFactory @JvmOverloads constructor( - private val typeUrlToAdapter: Map> = mapOf(), + typeUrlToAdapter: Map> = mapOf(), private val writeIdentityValues: Boolean = false, private val preservingProtoFieldNames: Boolean = false, ) : TypeAdapterFactory { + /** Built-in adapters are registered by default; entries in [typeUrlToAdapter] take precedence. */ + private val typeUrlToAdapter: Map> = buildMap { + put(ProtoAdapter.FIELD_MASK.typeUrl!!, ProtoAdapter.FIELD_MASK) + putAll(typeUrlToAdapter) + } + /** * Returns a new WireJsonAdapterFactory that can encode the messages for [adapters] if they're * used with [AnyMessage]. diff --git a/wire-moshi-adapter/src/main/java/com/squareup/wire/WireJsonAdapterFactory.kt b/wire-moshi-adapter/src/main/java/com/squareup/wire/WireJsonAdapterFactory.kt index eeba9e23b0..9f43b4ddbc 100644 --- a/wire-moshi-adapter/src/main/java/com/squareup/wire/WireJsonAdapterFactory.kt +++ b/wire-moshi-adapter/src/main/java/com/squareup/wire/WireJsonAdapterFactory.kt @@ -48,10 +48,16 @@ import java.lang.reflect.Type * precedence over the `jsonName` option. */ class WireJsonAdapterFactory @JvmOverloads constructor( - private val typeUrlToAdapter: Map> = mapOf(), + typeUrlToAdapter: Map> = mapOf(), private val writeIdentityValues: Boolean = false, private val preservingProtoFieldNames: Boolean = false, ) : JsonAdapter.Factory { + /** Built-in adapters are registered by default; entries in [typeUrlToAdapter] take precedence. */ + private val typeUrlToAdapter: Map> = buildMap { + put(ProtoAdapter.FIELD_MASK.typeUrl!!, ProtoAdapter.FIELD_MASK) + putAll(typeUrlToAdapter) + } + /** * Returns a new WireJsonAdapterFactory that can encode the messages for [adapters] if they're * used with [AnyMessage]. diff --git a/wire-protoc-compatibility-tests/src/test/java/com/squareup/wire/FieldMaskRoundTripTest.kt b/wire-protoc-compatibility-tests/src/test/java/com/squareup/wire/FieldMaskRoundTripTest.kt new file mode 100644 index 0000000000..562fc30b14 --- /dev/null +++ b/wire-protoc-compatibility-tests/src/test/java/com/squareup/wire/FieldMaskRoundTripTest.kt @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2026 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.wire + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.google.gson.GsonBuilder +import com.google.protobuf.FieldMask as GoogleFieldMask +import com.google.protobuf.util.JsonFormat +import com.squareup.moshi.Moshi +import com.squareup.wire.json.assertJsonEquals +import org.junit.Test + +class FieldMaskRoundTripTest { + private val moshi = Moshi.Builder() + .add(WireJsonAdapterFactory()) + .build() + + private val gson = GsonBuilder() + .registerTypeAdapterFactory(WireTypeAdapterFactory()) + .disableHtmlEscaping() + .create() + + @Test fun `binary round trip`() { + val googleMessage = GoogleFieldMask.newBuilder() + .addPaths("user.display_name") + .addPaths("photo") + .build() + + val wireMessage = FieldMask(listOf("user.display_name", "photo")) + + val googleMessageBytes = googleMessage.toByteArray() + assertThat(ProtoAdapter.FIELD_MASK.encode(wireMessage)).isEqualTo(googleMessageBytes) + assertThat(ProtoAdapter.FIELD_MASK.decode(googleMessageBytes)).isEqualTo(wireMessage) + assertThat(ProtoAdapter.FIELD_MASK.encodedSize(wireMessage)).isEqualTo(googleMessageBytes.size) + } + + @Test fun `empty binary round trip`() { + val googleMessage = GoogleFieldMask.newBuilder().build() + + val wireMessage = FieldMask() + + val googleMessageBytes = googleMessage.toByteArray() + assertThat(ProtoAdapter.FIELD_MASK.encode(wireMessage)).isEqualTo(googleMessageBytes) + assertThat(ProtoAdapter.FIELD_MASK.decode(googleMessageBytes)).isEqualTo(wireMessage) + assertThat(ProtoAdapter.FIELD_MASK.encodedSize(wireMessage)).isEqualTo(googleMessageBytes.size) + } + + @Test fun `json round trip`() { + val googleMessage = GoogleFieldMask.newBuilder() + .addPaths("user.display_name") + .addPaths("photo") + .addPaths("foo_bar.baz_qux") + .build() + + val wireMessage = FieldMask(listOf("user.display_name", "photo", "foo_bar.baz_qux")) + + val googleJson = JsonFormat.printer().print(googleMessage) + assertThat(googleJson).isEqualTo("\"user.displayName,photo,fooBar.bazQux\"") + + assertThat(moshi.adapter(FieldMask::class.java).toJson(wireMessage)).isEqualTo(googleJson) + assertThat(gson.toJson(wireMessage, FieldMask::class.java)).isEqualTo(googleJson) + + val googleParsed = GoogleFieldMask.newBuilder() + .apply { JsonFormat.parser().merge(googleJson, this) } + .build() + assertThat(googleParsed).isEqualTo(googleMessage) + assertThat(moshi.adapter(FieldMask::class.java).fromJson(googleJson)).isEqualTo(wireMessage) + assertThat(gson.fromJson(googleJson, FieldMask::class.java)).isEqualTo(wireMessage) + } + + @Test fun `degenerate paths match protobuf-java`() { + // These paths violate the protobuf style guide and don't survive the camelCase round trip. + // Wire intentionally matches protobuf-java's lossy conversions instead of erroring, so both + // the printed JSON and the re-parsed paths must be identical to protobuf-java's. + val paths = listOf("fooBar", "_foo", "foo_", "foo__bar", "foo_1bar", "Foo.Bar") + val googleMessage = GoogleFieldMask.newBuilder().addAllPaths(paths).build() + val wireMessage = FieldMask(paths) + + val googleJson = JsonFormat.printer().print(googleMessage) + assertThat(moshi.adapter(FieldMask::class.java).toJson(wireMessage)).isEqualTo(googleJson) + assertThat(gson.toJson(wireMessage, FieldMask::class.java)).isEqualTo(googleJson) + + val googleParsed = GoogleFieldMask.newBuilder() + .apply { JsonFormat.parser().merge(googleJson, this) } + .build() + val wireParsed = moshi.adapter(FieldMask::class.java).fromJson(googleJson)!! + assertThat(wireParsed.paths).isEqualTo(googleParsed.pathsList) + assertThat(gson.fromJson(googleJson, FieldMask::class.java)).isEqualTo(wireParsed) + } + + @Test fun `binary round trip packed in any`() { + val googleMessage = com.google.protobuf.Any.pack( + GoogleFieldMask.newBuilder() + .addPaths("user.display_name") + .addPaths("photo") + .build(), + ) + + val wireMessage = AnyMessage.pack( + ProtoAdapter.FIELD_MASK, + FieldMask(listOf("user.display_name", "photo")), + ) + + val googleMessageBytes = googleMessage.toByteArray() + assertThat(AnyMessage.ADAPTER.encode(wireMessage)).isEqualTo(googleMessageBytes) + assertThat(AnyMessage.ADAPTER.decode(googleMessageBytes)).isEqualTo(wireMessage) + } + + @Test fun `json round trip packed in any`() { + val googleMessage = com.google.protobuf.Any.pack( + GoogleFieldMask.newBuilder() + .addPaths("user.display_name") + .addPaths("photo") + .build(), + ) + + val wireMessage = AnyMessage.pack( + ProtoAdapter.FIELD_MASK, + FieldMask(listOf("user.display_name", "photo")), + ) + + val typeRegistry = JsonFormat.TypeRegistry.newBuilder() + .add(GoogleFieldMask.getDescriptor()) + .build() + + val googleJson = JsonFormat.printer().usingTypeRegistry(typeRegistry).print(googleMessage) + assertJsonEquals(googleJson, moshi.adapter(AnyMessage::class.java).toJson(wireMessage)) + assertJsonEquals(googleJson, gson.toJson(wireMessage, AnyMessage::class.java)) + + val googleParsed = com.google.protobuf.Any.newBuilder() + .apply { JsonFormat.parser().usingTypeRegistry(typeRegistry).merge(googleJson, this) } + .build() + assertThat(googleParsed).isEqualTo(googleMessage) + assertThat(moshi.adapter(AnyMessage::class.java).fromJson(googleJson)).isEqualTo(wireMessage) + assertThat(gson.fromJson(googleJson, AnyMessage::class.java)).isEqualTo(wireMessage) + } +} diff --git a/wire-runtime-swift/src/main/swift/FieldMask.swift b/wire-runtime-swift/src/main/swift/FieldMask.swift index 6b8071dc38..2f810dda78 100644 --- a/wire-runtime-swift/src/main/swift/FieldMask.swift +++ b/wire-runtime-swift/src/main/swift/FieldMask.swift @@ -136,13 +136,3 @@ extension FieldMask: Codable { } } #endif - -extension ProtoReader { - public func decode(_ type: FieldMask.Type, mergingInto existing: FieldMask?) throws -> FieldMask { - let decoded = try decode(type) - guard let existing = existing else { - return decoded - } - return FieldMask(paths: existing.paths + decoded.paths) - } -} diff --git a/wire-runtime-swift/src/main/swift/ProtoCodable/ProtoReader.swift b/wire-runtime-swift/src/main/swift/ProtoCodable/ProtoReader.swift index ae1e9eaa8a..c5fac27197 100644 --- a/wire-runtime-swift/src/main/swift/ProtoCodable/ProtoReader.swift +++ b/wire-runtime-swift/src/main/swift/ProtoCodable/ProtoReader.swift @@ -338,6 +338,23 @@ public final class ProtoReader { return try T(from: self) } + /** + Decode a message field, merging it with an existing value if one is present. + + Per the protobuf specification, when a singular embedded-message field appears multiple + times in the encoded input the occurrences are merged: singular scalar fields take the + value from the last occurrence, singular embedded-message fields are merged recursively, + and repeated fields are concatenated. + */ + public func decode(_ type: T.Type, mergingInto existing: T?) throws -> T { + guard let existing = existing else { + return try decode(type) + } + var data = try ProtoEncoder().encode(existing) + data.append(try decode(Data.self)) + return try ProtoDecoder(enumDecodingStrategy: enumDecodingStrategy).decode(type, from: data) + } + internal func decode(_ type: T.Type, withTag tag: UInt32) throws -> T { isProto3Message = T.self.protoSyntax == .proto3 return try decodeBoxed(tag: tag) { diff --git a/wire-runtime-swift/src/test/swift/ProtoDecoderTests.swift b/wire-runtime-swift/src/test/swift/ProtoDecoderTests.swift index 1e4018cf20..13cd063aad 100644 --- a/wire-runtime-swift/src/test/swift/ProtoDecoderTests.swift +++ b/wire-runtime-swift/src/test/swift/ProtoDecoderTests.swift @@ -25,6 +25,28 @@ final class ProtoDecoderTests: XCTestCase { XCTAssertEqual(object, SimpleOptional2()) } + func testDuplicatedSingularMessageFieldsAreMerged() throws { + // Encoding two messages back to back is equivalent to a single message in which the + // singular message field `nested` appears twice. Per the protobuf specification the + // occurrences merge: `name` takes the value of the last occurrence while + // `partially_redacted`, absent from the last occurrence, is kept from the first. + let first = Redacted(name: "message") { + $0.nested = Redacted2(name: "first") { + $0.partially_redacted = Redacted3(name: "kept") + } + } + let second = Redacted(name: "message") { + $0.nested = Redacted2(name: "last") + } + + var data = try ProtoEncoder().encode(first) + data.append(try ProtoEncoder().encode(second)) + let decoded = try ProtoDecoder().decode(Redacted.self, from: data) + + XCTAssertEqual(decoded.nested?.name, "last") + XCTAssertEqual(decoded.nested?.partially_redacted?.name, "kept") + } + func testDecodeEmptySizeDelimitedData() throws { let decoder = ProtoDecoder() let object = try decoder.decodeSizeDelimited(SimpleOptional2.self, from: Foundation.Data()) diff --git a/wire-runtime/src/commonMain/kotlin/com/squareup/wire/AnyMessage.kt b/wire-runtime/src/commonMain/kotlin/com/squareup/wire/AnyMessage.kt index 9e722a2d94..932a2df4c8 100644 --- a/wire-runtime/src/commonMain/kotlin/com/squareup/wire/AnyMessage.kt +++ b/wire-runtime/src/commonMain/kotlin/com/squareup/wire/AnyMessage.kt @@ -86,7 +86,7 @@ class AnyMessage( fun pack(adapter: ProtoAdapter, value: T): AnyMessage { val typeUrl = adapter.typeUrl - ?: error("recompile ${adapter.type ?: "type"} to use it with AnyMessage") + ?: error("cannot pack ${adapter.type ?: "value"}: the adapter has no type URL") return AnyMessage(typeUrl, adapter.encodeByteString(value)) } diff --git a/wire-runtime/src/commonMain/kotlin/com/squareup/wire/internal/RuntimeMessageAdapter.kt b/wire-runtime/src/commonMain/kotlin/com/squareup/wire/internal/RuntimeMessageAdapter.kt index 8e4946813f..9a518b3241 100644 --- a/wire-runtime/src/commonMain/kotlin/com/squareup/wire/internal/RuntimeMessageAdapter.kt +++ b/wire-runtime/src/commonMain/kotlin/com/squareup/wire/internal/RuntimeMessageAdapter.kt @@ -174,7 +174,7 @@ class RuntimeMessageAdapter( field.value(builder, value) } else { val singleAdapter = field.singleAdapter - if ((field.isMessage || singleAdapter == ProtoAdapter.FIELD_MASK) && + if ((field.isMessage || singleAdapter in MESSAGE_BACKED_BUILT_IN_ADAPTERS) && !field.label.isRepeated && !field.label.isOneOf ) { @@ -234,5 +234,31 @@ class RuntimeMessageAdapter( companion object { private const val REDACTED = "\u2588\u2588" + + /** + * Built-in adapters for the well-known message types that map to platform types instead of + * generated [Message] classes. Singular fields using these adapters merge duplicated + * occurrences like any other message field, matching generated code. + * [ProtoAdapter.STRUCT_NULL] is absent because `google.protobuf.NullValue` is an enum, not a + * message. + */ + private val MESSAGE_BACKED_BUILT_IN_ADAPTERS: Set> = setOf( + ProtoAdapter.DURATION, + ProtoAdapter.INSTANT, + ProtoAdapter.EMPTY, + ProtoAdapter.FIELD_MASK, + ProtoAdapter.STRUCT_MAP, + ProtoAdapter.STRUCT_VALUE, + ProtoAdapter.STRUCT_LIST, + ProtoAdapter.DOUBLE_VALUE, + ProtoAdapter.FLOAT_VALUE, + ProtoAdapter.INT64_VALUE, + ProtoAdapter.UINT64_VALUE, + ProtoAdapter.INT32_VALUE, + ProtoAdapter.UINT32_VALUE, + ProtoAdapter.BOOL_VALUE, + ProtoAdapter.STRING_VALUE, + ProtoAdapter.BYTES_VALUE, + ) } } diff --git a/wire-runtime/src/commonTest/kotlin/com/squareup/wire/FieldMaskTest.kt b/wire-runtime/src/commonTest/kotlin/com/squareup/wire/FieldMaskTest.kt index a80c179125..af31003ff5 100644 --- a/wire-runtime/src/commonTest/kotlin/com/squareup/wire/FieldMaskTest.kt +++ b/wire-runtime/src/commonTest/kotlin/com/squareup/wire/FieldMaskTest.kt @@ -65,4 +65,11 @@ class FieldMaskTest { assertThat(ProtoAdapter.FIELD_MASK.typeUrl) .isEqualTo("type.googleapis.com/google.protobuf.FieldMask") } + + @Test fun protoAdapterDiscardsUnknownFields() { + // Path "a", followed by unknown field 2 with varint value 5. + val bytes = "0a01611005".decodeHex() + + assertThat(ProtoAdapter.FIELD_MASK.decode(bytes)).isEqualTo(FieldMask(listOf("a"))) + } } diff --git a/wire-runtime/src/jvmMain/kotlin/com/squareup/wire/internal/FieldMaskJsonFormatter.kt b/wire-runtime/src/jvmMain/kotlin/com/squareup/wire/internal/FieldMaskJsonFormatter.kt index f8a69890e0..083ce1a4f0 100644 --- a/wire-runtime/src/jvmMain/kotlin/com/squareup/wire/internal/FieldMaskJsonFormatter.kt +++ b/wire-runtime/src/jvmMain/kotlin/com/squareup/wire/internal/FieldMaskJsonFormatter.kt @@ -17,7 +17,13 @@ package com.squareup.wire.internal import com.squareup.wire.FieldMask -/** Encodes a field mask as a JSON string like "user.displayName,photo". */ +/** + * Encodes a field mask as a JSON string like "user.displayName,photo". + * + * The case conversions match protobuf-java's `FieldMaskUtil` and are not lossless for paths that + * violate the protobuf style guide: underscores followed by numbers, consecutive underscores, and + * leading, trailing, or uppercase characters do not survive a round trip. + */ object FieldMaskJsonFormatter : JsonFormatter { override fun toStringOrNumber(value: FieldMask): String = value.paths .filter { it.isNotEmpty() } diff --git a/wire-schema/src/jvmMain/resources/google/protobuf/field_mask.proto b/wire-schema/src/jvmMain/resources/google/protobuf/field_mask.proto index 814d58bf91..7093fba50d 100644 --- a/wire-schema/src/jvmMain/resources/google/protobuf/field_mask.proto +++ b/wire-schema/src/jvmMain/resources/google/protobuf/field_mask.proto @@ -32,14 +32,212 @@ syntax = "proto3"; package google.protobuf; -option go_package = "google.golang.org/protobuf/types/known/fieldmaskpb"; option java_package = "com.google.protobuf"; option java_outer_classname = "FieldMaskProto"; option java_multiple_files = true; option objc_class_prefix = "GPB"; option csharp_namespace = "Google.Protobuf.WellKnownTypes"; +option go_package = "google.golang.org/protobuf/types/known/fieldmaskpb"; option cc_enable_arenas = true; +// `FieldMask` represents a set of symbolic field paths, for example: +// +// paths: "f.a" +// paths: "f.b.d" +// +// Here `f` represents a field in some root message, `a` and `b` +// fields in the message found in `f`, and `d` a field found in the +// message in `f.b`. +// +// Field masks are used to specify a subset of fields that should be +// returned by a get operation or modified by an update operation. +// Field masks also have a custom JSON encoding (see below). +// +// # Field Masks in Projections +// +// When used in the context of a projection, a response message or +// sub-message is filtered by the API to only contain those fields as +// specified in the mask. For example, if the mask in the previous +// example is applied to a response message as follows: +// +// f { +// a : 22 +// b { +// d : 1 +// x : 2 +// } +// y : 13 +// } +// z: 8 +// +// The result will not contain specific values for fields x,y and z +// (their value will be set to the default, and omitted in proto text +// output): +// +// +// f { +// a : 22 +// b { +// d : 1 +// } +// } +// +// A repeated field is not allowed except at the last position of a +// paths string. +// +// If a FieldMask object is not present in a get operation, the +// operation applies to all fields (as if a FieldMask of all fields +// had been specified). +// +// Note that a field mask does not necessarily apply to the +// top-level response message. In case of a REST get operation, the +// field mask applies directly to the response, but in case of a REST +// list operation, the mask instead applies to each individual message +// in the returned resource list. In case of a REST custom method, +// other definitions may be used. Where the mask applies will be +// clearly documented together with its declaration in the API. In +// any case, the effect on the returned resource/resources is required +// behavior for APIs. +// +// # Field Masks in Update Operations +// +// A field mask in update operations specifies which fields of the +// targeted resource are going to be updated. The API is required +// to only change the values of the fields as specified in the mask +// and leave the others untouched. If a resource is passed in to +// describe the updated values, the API ignores the values of all +// fields not covered by the mask. +// +// If a repeated field is specified for an update operation, new values will +// be appended to the existing repeated field in the target resource. Note that +// a repeated field is only allowed in the last position of a `paths` string. +// +// If a sub-message is specified in the last position of the field mask for an +// update operation, then new value will be merged into the existing sub-message +// in the target resource. +// +// For example, given the target message: +// +// f { +// b { +// d: 1 +// x: 2 +// } +// c: [1] +// } +// +// And an update message: +// +// f { +// b { +// d: 10 +// } +// c: [2] +// } +// +// then if the field mask is: +// +// paths: ["f.b", "f.c"] +// +// then the result will be: +// +// f { +// b { +// d: 10 +// x: 2 +// } +// c: [1, 2] +// } +// +// An implementation may provide options to override this default behavior for +// repeated and message fields. +// +// Note that libraries which implement FieldMask resolution have various +// different behaviors in the face of empty masks or the special "*" mask. +// When implementing a service you should confirm these cases have the +// appropriate behavior in the underlying FieldMask library that you desire, +// and you may need to special case those cases in your application code if +// the underlying field mask library behavior differs from your intended +// service semantics. +// +// Update methods implementing https://google.aip.dev/134 +// - MUST support the special value * meaning "full replace" +// - MUST treat an omitted field mask as "replace fields which are present". +// +// Other methods implementing https://google.aip.dev/157 +// - SHOULD support the special value "*" to mean "get all". +// - MUST treat an omitted field mask to mean "get all", unless otherwise +// documented. +// +// ## Considerations for HTTP REST +// +// The HTTP kind of an update operation which uses a field mask must +// be set to PATCH instead of PUT in order to satisfy HTTP semantics +// (PUT must only be used for full updates). +// +// # JSON Encoding of Field Masks +// +// In JSON, a field mask is encoded as a single string where paths are +// separated by a comma. Fields name in each path are converted +// to/from lower-camel naming conventions. +// +// As an example, consider the following message declarations: +// +// message Profile { +// User user = 1; +// Photo photo = 2; +// } +// message User { +// string display_name = 1; +// string address = 2; +// } +// +// In proto a field mask for `Profile` may look as such: +// +// mask { +// paths: "user.display_name" +// paths: "photo" +// } +// +// In JSON, the same mask is represented as below: +// +// { +// mask: "user.displayName,photo" +// } +// +// # Field Masks and Oneof Fields +// +// Field masks treat fields in oneofs just as regular fields. Consider the +// following message: +// +// message SampleMessage { +// oneof test_oneof { +// string name = 4; +// SubMessage sub_message = 9; +// } +// } +// +// The field mask can be: +// +// mask { +// paths: "name" +// } +// +// Or: +// +// mask { +// paths: "sub_message" +// } +// +// Note that oneof type names ("test_oneof" in this case) cannot be used in +// paths. +// +// ## Field Mask Verification +// +// The implementation of any API method which has a FieldMask type field in the +// request should verify the included field paths, and return an +// `INVALID_ARGUMENT` error if any path is unmappable. message FieldMask { + // The set of field mask paths. repeated string paths = 1; } diff --git a/wire-schema/src/jvmTest/kotlin/com/squareup/wire/schema/DynamicSerializationTest.kt b/wire-schema/src/jvmTest/kotlin/com/squareup/wire/schema/DynamicSerializationTest.kt index 679ac0bb3f..1e5bdcb918 100644 --- a/wire-schema/src/jvmTest/kotlin/com/squareup/wire/schema/DynamicSerializationTest.kt +++ b/wire-schema/src/jvmTest/kotlin/com/squareup/wire/schema/DynamicSerializationTest.kt @@ -112,6 +112,31 @@ class DynamicSerializationTest { .isEqualTo(mapOf("field_mask_field" to FieldMask(listOf("a", "b")))) } + @Test + fun singularDurationOccurrencesAreMerged() { + val schema = buildSchema { + add( + "message.proto".toPath(), + """ + |syntax = "proto3"; + |import "google/protobuf/duration.proto"; + | + |message Message { + | google.protobuf.Duration duration_field = 1; + |} + | + """.trimMargin(), + ) + } + + val adapter = schema.protoAdapter(typeName = "Message", includeUnknown = true) + // Tag 1 twice: `{seconds: 5}` then `{nanos: 3}`. + val encoded = "0a0208050a021003".decodeHex() + + assertThat(adapter.decode(encoded)) + .isEqualTo(mapOf("duration_field" to durationOfSeconds(5L, 3L))) + } + @Ignore // Not supported. @Test fun mapTest() { diff --git a/wire-swift-generator/src/main/java/com/squareup/wire/swift/SwiftGenerator.kt b/wire-swift-generator/src/main/java/com/squareup/wire/swift/SwiftGenerator.kt index fd43446bf7..b1a7770546 100644 --- a/wire-swift-generator/src/main/java/com/squareup/wire/swift/SwiftGenerator.kt +++ b/wire-swift-generator/src/main/java/com/squareup/wire/swift/SwiftGenerator.kt @@ -635,7 +635,9 @@ class SwiftGenerator private constructor( } else { if (field.isRepeated) { decoder.add("try $reader.decode(into: &%N", field.safeName) - } else if (field.type == ProtoType.FIELD_MASK) { + } else if (field.isMessage) { + // Duplicated occurrences of a singular message field are merged per the protobuf + // specification, matching generated Kotlin and Java. val typeName = field.typeName.makeNonOptional() decoder.add("%N = try $reader.decode(%T.self, mergingInto: %N", field.safeName, typeName, field.safeName) diff --git a/wire-swift-generator/src/test/java/com/squareup/wire/swift/SwiftGeneratorTest.kt b/wire-swift-generator/src/test/java/com/squareup/wire/swift/SwiftGeneratorTest.kt index c8ec5b2ae2..6ec03f163d 100644 --- a/wire-swift-generator/src/test/java/com/squareup/wire/swift/SwiftGeneratorTest.kt +++ b/wire-swift-generator/src/test/java/com/squareup/wire/swift/SwiftGeneratorTest.kt @@ -79,6 +79,10 @@ class SwiftGeneratorTest { | google.protobuf.FieldMask mask = 1; | repeated google.protobuf.FieldMask masks = 2; | map masks_by_id = 3; + | oneof choice { + | google.protobuf.FieldMask oneof_mask = 4; + | string name = 5; + | } |} """.trimMargin(), ) @@ -90,12 +94,46 @@ class SwiftGeneratorTest { assertThat(code).contains("public var mask: FieldMask?") assertThat(code).contains("public var masks: [FieldMask]") assertThat(code).contains("public var masks_by_id: [Int32 : FieldMask]") + assertThat(code).contains("case oneof_mask(FieldMask)") assertThat(code).contains("mask = try protoReader.decode(FieldMask.self, mergingInto: mask)") assertThat(code).contains("try protoReader.decode(into: &masks)") assertThat(code).contains("try protoReader.decode(into: &masks_by_id, keyEncoding: .variable)") + assertThat(code).contains("case 4: choice = .oneof_mask(try protoReader.decode(FieldMask.self))") assertThat(code).doesNotContain("@ProtoDefaulted") } + @Test fun mergesDuplicatedSingularMessageFields() { + val schema = buildSchema { + add( + "message.proto".toPath(), + """ + |syntax = "proto3"; + | + |package squareup.protos3; + | + |message Other { + | string name = 1; + |} + | + |message Message { + | Other other = 1; + | repeated Other others = 2; + | oneof choice { + | Other oneof_other = 3; + | } + |} + """.trimMargin(), + ) + } + + val code = schema.generateSwift("squareup.protos3.Message") + + assertThat(code).contains("other = try protoReader.decode(Other.self, mergingInto: other)") + // Repeated and oneof message fields don't merge. + assertThat(code).contains("try protoReader.decode(into: &others)") + assertThat(code).contains("case 3: choice = .oneof_other(try protoReader.decode(Other.self))") + } + private fun Schema.generateSwift(typeName: String): String { val swiftGenerator = SwiftGenerator(this) val type = requireNotNull(getType(typeName)) diff --git a/wire-tests-proto3-swift/src/main/swift/ContainsDuration.swift b/wire-tests-proto3-swift/src/main/swift/ContainsDuration.swift index d7a25c9fb7..7719fdc22f 100644 --- a/wire-tests-proto3-swift/src/main/swift/ContainsDuration.swift +++ b/wire-tests-proto3-swift/src/main/swift/ContainsDuration.swift @@ -49,7 +49,7 @@ extension ContainsDuration : Proto3Codable { let token = try protoReader.beginMessage() while let tag = try protoReader.nextTag(token: token) { switch tag { - case 1: duration = try protoReader.decode(Duration.self) + case 1: duration = try protoReader.decode(Duration.self, mergingInto: duration) default: try protoReader.readUnknownField(tag: tag) } } diff --git a/wire-tests-proto3-swift/src/main/swift/ContainsTimestamp.swift b/wire-tests-proto3-swift/src/main/swift/ContainsTimestamp.swift index 47fe54cb84..696ae3b295 100644 --- a/wire-tests-proto3-swift/src/main/swift/ContainsTimestamp.swift +++ b/wire-tests-proto3-swift/src/main/swift/ContainsTimestamp.swift @@ -49,7 +49,7 @@ extension ContainsTimestamp : Proto3Codable { let token = try protoReader.beginMessage() while let tag = try protoReader.nextTag(token: token) { switch tag { - case 1: timestamp = try protoReader.decode(Timestamp.self) + case 1: timestamp = try protoReader.decode(Timestamp.self, mergingInto: timestamp) default: try protoReader.readUnknownField(tag: tag) } } diff --git a/wire-tests-swift/manifest/module_one/SwiftModuleOneAmbiguousMessage.swift b/wire-tests-swift/manifest/module_one/SwiftModuleOneAmbiguousMessage.swift index f5477ccf71..2f80631726 100644 --- a/wire-tests-swift/manifest/module_one/SwiftModuleOneAmbiguousMessage.swift +++ b/wire-tests-swift/manifest/module_one/SwiftModuleOneAmbiguousMessage.swift @@ -57,8 +57,8 @@ extension SwiftModuleOneAmbiguousMessage : Proto2Codable { while let tag = try protoReader.nextTag(token: token) { switch tag { case 1: name = try protoReader.decode(String.self) - case 2: address = try protoReader.decode(module_address.Address.self) - case 3: location = try protoReader.decode(Location.self) + case 2: address = try protoReader.decode(module_address.Address.self, mergingInto: address) + case 3: location = try protoReader.decode(Location.self, mergingInto: location) default: try protoReader.readUnknownField(tag: tag) } } diff --git a/wire-tests-swift/manifest/module_three/DateTime.swift b/wire-tests-swift/manifest/module_three/DateTime.swift index 32ee932492..08d2a5e866 100644 --- a/wire-tests-swift/manifest/module_three/DateTime.swift +++ b/wire-tests-swift/manifest/module_three/DateTime.swift @@ -50,7 +50,7 @@ extension DateTime : Proto2Codable { let token = try protoReader.beginMessage() while let tag = try protoReader.nextTag(token: token) { switch tag { - case 1: value = try protoReader.decode(module_one.DateTime.self) + case 1: value = try protoReader.decode(module_one.DateTime.self, mergingInto: value) default: try protoReader.readUnknownField(tag: tag) } } diff --git a/wire-tests-swift/manifest/module_two/SwiftModuleTwoMessage.swift b/wire-tests-swift/manifest/module_two/SwiftModuleTwoMessage.swift index 80007e09bd..3bd93668a7 100644 --- a/wire-tests-swift/manifest/module_two/SwiftModuleTwoMessage.swift +++ b/wire-tests-swift/manifest/module_two/SwiftModuleTwoMessage.swift @@ -148,7 +148,7 @@ extension SwiftModuleTwoMessage.NestedMessage : Proto2Codable { while let tag = try protoReader.nextTag(token: token) { switch tag { case 1: try protoReader.decode(into: &array_types) - case 2: module_type = try protoReader.decode(SwiftModuleOneMessage.self) + case 2: module_type = try protoReader.decode(SwiftModuleOneMessage.self, mergingInto: module_type) default: try protoReader.readUnknownField(tag: tag) } } diff --git a/wire-tests-swift/no-manifest/src/main/swift/AllTypes.swift b/wire-tests-swift/no-manifest/src/main/swift/AllTypes.swift index 3e45cda50f..d9a793a773 100644 --- a/wire-tests-swift/no-manifest/src/main/swift/AllTypes.swift +++ b/wire-tests-swift/no-manifest/src/main/swift/AllTypes.swift @@ -2393,7 +2393,7 @@ extension AllTypes.Storage : Proto2Codable { case 14: opt_string = try protoReader.decode(String.self) case 15: opt_bytes = try protoReader.decode(Foundation.Data.self) case 16: opt_nested_enum = try protoReader.decode(AllTypes.NestedEnum.self) - case 17: opt_nested_message = try protoReader.decode(AllTypes.NestedMessage.self) + case 17: opt_nested_message = try protoReader.decode(AllTypes.NestedMessage.self, mergingInto: opt_nested_message) case 18: opt_field_mask = try protoReader.decode(FieldMask.self, mergingInto: opt_field_mask) case 101: req_int32 = try protoReader.decode(Int32.self, encoding: .variable) case 102: req_uint32 = try protoReader.decode(UInt32.self, encoding: .variable) @@ -2411,7 +2411,7 @@ extension AllTypes.Storage : Proto2Codable { case 114: req_string = try protoReader.decode(String.self) case 115: req_bytes = try protoReader.decode(Foundation.Data.self) case 116: req_nested_enum = try protoReader.decode(AllTypes.NestedEnum.self) - case 117: req_nested_message = try protoReader.decode(AllTypes.NestedMessage.self) + case 117: req_nested_message = try protoReader.decode(AllTypes.NestedMessage.self, mergingInto: req_nested_message) case 201: try protoReader.decode(into: &rep_int32, encoding: .variable) case 202: try protoReader.decode(into: &rep_uint32, encoding: .variable) case 203: try protoReader.decode(into: &rep_sint32, encoding: .signed) diff --git a/wire-tests-swift/no-manifest/src/main/swift/FooBar.swift b/wire-tests-swift/no-manifest/src/main/swift/FooBar.swift index 5437c3edbe..4ac0ca2880 100644 --- a/wire-tests-swift/no-manifest/src/main/swift/FooBar.swift +++ b/wire-tests-swift/no-manifest/src/main/swift/FooBar.swift @@ -130,7 +130,7 @@ extension FooBar : Proto2Codable { switch tag { case 1: foo = try protoReader.decode(Int32.self, encoding: .variable) case 2: bar = try protoReader.decode(String.self) - case 3: baz = try protoReader.decode(FooBar.Nested.self) + case 3: baz = try protoReader.decode(FooBar.Nested.self, mergingInto: baz) case 4: qux = try protoReader.decode(UInt64.self, encoding: .variable) case 5: try protoReader.decode(into: &fred) case 6: daisy = try protoReader.decode(Double.self) diff --git a/wire-tests-swift/no-manifest/src/main/swift/OuterMessage.swift b/wire-tests-swift/no-manifest/src/main/swift/OuterMessage.swift index 97c39be24e..ac2f37d8a2 100644 --- a/wire-tests-swift/no-manifest/src/main/swift/OuterMessage.swift +++ b/wire-tests-swift/no-manifest/src/main/swift/OuterMessage.swift @@ -54,7 +54,7 @@ extension OuterMessage : Proto2Codable { while let tag = try protoReader.nextTag(token: token) { switch tag { case 1: outer_number_before = try protoReader.decode(Int32.self, encoding: .variable) - case 2: embedded_message = try protoReader.decode(EmbeddedMessage.self) + case 2: embedded_message = try protoReader.decode(EmbeddedMessage.self, mergingInto: embedded_message) default: try protoReader.readUnknownField(tag: tag) } } diff --git a/wire-tests-swift/no-manifest/src/main/swift/VersionOne.swift b/wire-tests-swift/no-manifest/src/main/swift/VersionOne.swift index de75be2aa5..5b2ad94e9d 100644 --- a/wire-tests-swift/no-manifest/src/main/swift/VersionOne.swift +++ b/wire-tests-swift/no-manifest/src/main/swift/VersionOne.swift @@ -56,7 +56,7 @@ extension VersionOne : Proto2Codable { while let tag = try protoReader.nextTag(token: token) { switch tag { case 1: i = try protoReader.decode(Int32.self, encoding: .variable) - case 7: obj = try protoReader.decode(NestedVersionOne.self) + case 7: obj = try protoReader.decode(NestedVersionOne.self, mergingInto: obj) case 8: en = try protoReader.decode(EnumVersionOne.self) default: try protoReader.readUnknownField(tag: tag) } diff --git a/wire-tests-swift/no-manifest/src/main/swift/VersionTwo.swift b/wire-tests-swift/no-manifest/src/main/swift/VersionTwo.swift index 29922b1400..0c536de5a7 100644 --- a/wire-tests-swift/no-manifest/src/main/swift/VersionTwo.swift +++ b/wire-tests-swift/no-manifest/src/main/swift/VersionTwo.swift @@ -75,7 +75,7 @@ extension VersionTwo : Proto2Codable { case 4: v2_f32 = try protoReader.decode(UInt32.self, encoding: .fixed) case 5: v2_f64 = try protoReader.decode(UInt64.self, encoding: .fixed) case 6: try protoReader.decode(into: &v2_rs) - case 7: obj = try protoReader.decode(NestedVersionTwo.self) + case 7: obj = try protoReader.decode(NestedVersionTwo.self, mergingInto: obj) case 8: en = try protoReader.decode(EnumVersionTwo.self) default: try protoReader.readUnknownField(tag: tag) } diff --git a/wire-tests/fixtures/shared/proto/proto3/contains_field_mask.proto b/wire-tests/fixtures/shared/proto/proto3/contains_field_mask.proto index ef5b40c199..0452b8e06d 100644 --- a/wire-tests/fixtures/shared/proto/proto3/contains_field_mask.proto +++ b/wire-tests/fixtures/shared/proto/proto3/contains_field_mask.proto @@ -24,4 +24,5 @@ message ContainsFieldMask { google.protobuf.FieldMask mask = 1; repeated google.protobuf.FieldMask masks = 2; google.protobuf.Any any_mask = 3; + map masks_by_name = 4; } diff --git a/wire-tests/wire-json-shared-kotlin-tests/com/squareup/wire/WireJsonTest.kt b/wire-tests/wire-json-shared-kotlin-tests/com/squareup/wire/WireJsonTest.kt index 9cfd57e5b3..cb6c31c116 100644 --- a/wire-tests/wire-json-shared-kotlin-tests/com/squareup/wire/WireJsonTest.kt +++ b/wire-tests/wire-json-shared-kotlin-tests/com/squareup/wire/WireJsonTest.kt @@ -285,8 +285,15 @@ class WireJsonTest { ProtoAdapter.FIELD_MASK.encodeByteString(anyFieldMask), ), ) + .masks_by_name( + mapOf( + "profile" to FieldMask(listOf("user.display_name", "photo")), + "audit" to FieldMask(listOf("updated_at.seconds")), + ), + ) .build() val anyFieldName = if (jsonLibrary.preservingProtoFieldNames) "any_mask" else "anyMask" + val mapFieldName = if (jsonLibrary.preservingProtoFieldNames) "masks_by_name" else "masksByName" val json = """ |{ | "mask": "user.displayName,photo,fooBar.bazQux", @@ -294,6 +301,10 @@ class WireJsonTest { | "$anyFieldName": { | "@type": "type.googleapis.com/google.protobuf.FieldMask", | "value": "maskedName" + | }, + | "$mapFieldName": { + | "profile": "user.displayName,photo", + | "audit": "updatedAt.seconds" | } |} """.trimMargin() @@ -308,12 +319,31 @@ class WireJsonTest { ) } + @Test fun emptyFieldMask() { + val value = ContainsFieldMask.Builder() + .mask(FieldMask()) + .build() + // An empty mask is present, so it is emitted as the empty string rather than omitted. + val mapFieldName = if (jsonLibrary.preservingProtoFieldNames) "masks_by_name" else "masksByName" + val json = if (jsonLibrary.writeIdentityValues) { + """{"mask": "", "masks": [], "$mapFieldName": {}}""" + } else { + """{"mask": ""}""" + } + + assertJsonEquals(json, jsonLibrary.toJson(value, ContainsFieldMask::class.java)) + assertThat(jsonLibrary.fromJson(json, ContainsFieldMask::class.java)).isEqualTo(value) + } + @Test fun rootFieldMask() { val value = FieldMask(listOf("user.display_name", "photo", "foo_bar.baz_qux")) val json = "\"user.displayName,photo,fooBar.bazQux\"" assertThat(jsonLibrary.toJson(value, FieldMask::class.java)).isEqualTo(json) assertThat(jsonLibrary.fromJson(json, FieldMask::class.java)).isEqualTo(value) + + assertThat(jsonLibrary.toJson(FieldMask(), FieldMask::class.java)).isEqualTo("\"\"") + assertThat(jsonLibrary.fromJson("\"\"", FieldMask::class.java)).isEqualTo(FieldMask()) } @Test fun singularFieldMaskOccurrencesAreMerged() { @@ -322,6 +352,15 @@ class WireJsonTest { assertThat(value.mask).isEqualTo(FieldMask(listOf("a", "b"))) } + @Test fun singularWellKnownTypeOccurrencesAreMergedByRuntimeMessageAdapter() { + val adapter = ProtoAdapter.newMessageAdapter(PizzaDelivery::class.java) + + // Tag 5 (delivered_within_or_free) twice: `{seconds: 5}` then `{nanos: 3}`. + val value = adapter.decode("2a0208052a021003".decodeHex()) + + assertThat(value.delivered_within_or_free).isEqualTo(durationOfSeconds(5L, 3L)) + } + @Test fun anyMessageWithUnregisteredTypeOnReading() { try { jsonLibrary.fromJson(PIZZA_DELIVERY_UNKNOWN_TYPE_JSON, PizzaDelivery::class.java) @@ -1250,7 +1289,7 @@ class WireJsonTest { private val moshi = Moshi.Builder() .add( WireJsonAdapterFactory(writeIdentityValues = writeIdentityValues, preservingProtoFieldNames = preservingProtoFieldNames) - .plus(listOf(BuyOneGetOnePromotion.ADAPTER, ProtoAdapter.FIELD_MASK)), + .plus(listOf(BuyOneGetOnePromotion.ADAPTER)), ) .build() @@ -1272,7 +1311,7 @@ class WireJsonTest { private val gson = GsonBuilder() .registerTypeAdapterFactory( WireTypeAdapterFactory(writeIdentityValues = writeIdentityValues, preservingProtoFieldNames = preservingProtoFieldNames) - .plus(listOf(BuyOneGetOnePromotion.ADAPTER, ProtoAdapter.FIELD_MASK)), + .plus(listOf(BuyOneGetOnePromotion.ADAPTER)), ) .disableHtmlEscaping() .create() @@ -1295,7 +1334,7 @@ class WireJsonTest { private val moshi = Moshi.Builder() .add( WireJsonAdapterFactory(writeIdentityValues = writeIdentityValues, preservingProtoFieldNames = preservingProtoFieldNames) - .plus(listOf(BuyOneGetOnePromotion.ADAPTER, ProtoAdapter.FIELD_MASK)), + .plus(listOf(BuyOneGetOnePromotion.ADAPTER)), ) .build() @@ -1317,7 +1356,7 @@ class WireJsonTest { private val gson = GsonBuilder() .registerTypeAdapterFactory( WireTypeAdapterFactory(writeIdentityValues = writeIdentityValues, preservingProtoFieldNames = preservingProtoFieldNames) - .plus(listOf(BuyOneGetOnePromotion.ADAPTER, ProtoAdapter.FIELD_MASK)), + .plus(listOf(BuyOneGetOnePromotion.ADAPTER)), ) .disableHtmlEscaping() .create() @@ -1340,7 +1379,7 @@ class WireJsonTest { private val moshi = Moshi.Builder() .add( WireJsonAdapterFactory(writeIdentityValues = writeIdentityValues, preservingProtoFieldNames = preservingProtoFieldNames) - .plus(listOf(BuyOneGetOnePromotion.ADAPTER, ProtoAdapter.FIELD_MASK)), + .plus(listOf(BuyOneGetOnePromotion.ADAPTER)), ) .build() @@ -1362,7 +1401,7 @@ class WireJsonTest { private val gson = GsonBuilder() .registerTypeAdapterFactory( WireTypeAdapterFactory(writeIdentityValues = writeIdentityValues, preservingProtoFieldNames = preservingProtoFieldNames) - .plus(listOf(BuyOneGetOnePromotion.ADAPTER, ProtoAdapter.FIELD_MASK)), + .plus(listOf(BuyOneGetOnePromotion.ADAPTER)), ) .disableHtmlEscaping() .create()