Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------------------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,16 @@ import com.squareup.wire.internal.createRuntimeMessageAdapter
* precedence over the `jsonName` option.
*/
class WireTypeAdapterFactory @JvmOverloads constructor(
private val typeUrlToAdapter: Map<String, ProtoAdapter<*>> = mapOf(),
typeUrlToAdapter: Map<String, ProtoAdapter<*>> = 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<String, ProtoAdapter<*>> = 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].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,16 @@ import java.lang.reflect.Type
* precedence over the `jsonName` option.
*/
class WireJsonAdapterFactory @JvmOverloads constructor(
private val typeUrlToAdapter: Map<String, ProtoAdapter<*>> = mapOf(),
typeUrlToAdapter: Map<String, ProtoAdapter<*>> = 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<String, ProtoAdapter<*>> = 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].
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
10 changes: 0 additions & 10 deletions wire-runtime-swift/src/main/swift/FieldMask.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
17 changes: 17 additions & 0 deletions wire-runtime-swift/src/main/swift/ProtoCodable/ProtoReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: ProtoCodable>(_ 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<T: ProtoDecodable>(_ type: T.Type, withTag tag: UInt32) throws -> T {
isProto3Message = T.self.protoSyntax == .proto3
return try decodeBoxed(tag: tag) {
Expand Down
22 changes: 22 additions & 0 deletions wire-runtime-swift/src/test/swift/ProtoDecoderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ class AnyMessage(

fun <T> pack(adapter: ProtoAdapter<T>, 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))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ class RuntimeMessageAdapter<M : Any, B : Any>(
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
) {
Expand Down Expand Up @@ -234,5 +234,31 @@ class RuntimeMessageAdapter<M : Any, B : Any>(

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<ProtoAdapter<*>> = 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,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<FieldMask> {
override fun toStringOrNumber(value: FieldMask): String = value.paths
.filter { it.isNotEmpty() }
Expand Down
Loading
Loading