diff --git a/wire-runtime-swift/src/main/swift/FieldMask.swift b/wire-runtime-swift/src/main/swift/FieldMask.swift index 1ec6faa398..d9c803b51f 100644 --- a/wire-runtime-swift/src/main/swift/FieldMask.swift +++ b/wire-runtime-swift/src/main/swift/FieldMask.swift @@ -79,14 +79,17 @@ extension FieldMask: Codable { public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let value = try container.decode(String.self) - self.paths = value.isEmpty - ? [] - : value.split(separator: ",").map { FieldMask.protoName(String($0)) } + self.paths = value.split(separator: ",").map { FieldMask.protoName(String($0)) } } public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() - try container.encode(paths.map(FieldMask.jsonName).joined(separator: ",")) + try container.encode( + paths + .filter { !$0.isEmpty } + .map(FieldMask.jsonName) + .joined(separator: ",") + ) } private static func jsonName(_ path: String) -> String { @@ -113,7 +116,7 @@ extension FieldMask: Codable { result.append(String(scalar).uppercased()) capitalizeNext = false } else { - result.append(String(scalar)) + result.append(String(scalar).lowercased()) } } return result @@ -128,7 +131,7 @@ extension FieldMask: Codable { } result.append(String(scalar).lowercased()) } else { - result.append(String(scalar)) + result.append(String(scalar).lowercased()) } } return result diff --git a/wire-runtime-swift/src/test/swift/FieldMaskTests.swift b/wire-runtime-swift/src/test/swift/FieldMaskTests.swift index e6381af554..42709a21b3 100644 --- a/wire-runtime-swift/src/test/swift/FieldMaskTests.swift +++ b/wire-runtime-swift/src/test/swift/FieldMaskTests.swift @@ -54,6 +54,19 @@ final class FieldMaskTests: XCTestCase { XCTAssertEqual(String(data: encoded, encoding: .utf8), #""user.displayName,photo""#) } + func testJSONSkipsEmptyPaths() throws { + let fieldMask = FieldMask(paths: ["", "photo", ""]) + + let encoded = try JSONEncoder().encode(fieldMask) + let decoded = try JSONDecoder().decode( + FieldMask.self, + from: Foundation.Data("\",photo,\"".utf8) + ) + + XCTAssertEqual(String(data: encoded, encoding: .utf8), #""photo""#) + XCTAssertEqual(decoded, FieldMask(paths: ["photo"])) + } + func testGeneratedMessageWithFieldMask() throws { let message = MessageContainingFieldMask { $0.mask = FieldMask(paths: ["user.display_name", "photo"]) 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 new file mode 100644 index 0000000000..f8a69890e0 --- /dev/null +++ b/wire-runtime/src/jvmMain/kotlin/com/squareup/wire/internal/FieldMaskJsonFormatter.kt @@ -0,0 +1,58 @@ +/* + * 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.internal + +import com.squareup.wire.FieldMask + +/** Encodes a field mask as a JSON string like "user.displayName,photo". */ +object FieldMaskJsonFormatter : JsonFormatter { + override fun toStringOrNumber(value: FieldMask): String = value.paths + .filter { it.isNotEmpty() } + .joinToString(separator = ",") { it.lowerUnderscoreToLowerCamel() } + + override fun fromString(value: String): FieldMask = FieldMask( + value.split(',') + .filter { it.isNotEmpty() } + .map { it.lowerCamelToLowerUnderscore() }, + ) + + private fun String.lowerUnderscoreToLowerCamel(): String { + val result = StringBuilder() + var capitalizeNext = false + for (c in this) { + if (c == '_') { + capitalizeNext = true + } else if (capitalizeNext) { + result.append(c.uppercaseChar()) + capitalizeNext = false + } else { + result.append(c.lowercaseChar()) + } + } + return result.toString() + } + + private fun String.lowerCamelToLowerUnderscore(): String { + val result = StringBuilder() + for (c in this) { + if (c in 'A'..'Z') { + result.append('_') + } + result.append(c.lowercaseChar()) + } + return result.toString() + } +} diff --git a/wire-runtime/src/jvmMain/kotlin/com/squareup/wire/internal/JsonIntegration.kt b/wire-runtime/src/jvmMain/kotlin/com/squareup/wire/internal/JsonIntegration.kt index 83274a610a..129ea1256e 100644 --- a/wire-runtime/src/jvmMain/kotlin/com/squareup/wire/internal/JsonIntegration.kt +++ b/wire-runtime/src/jvmMain/kotlin/com/squareup/wire/internal/JsonIntegration.kt @@ -103,6 +103,7 @@ abstract class JsonIntegration { ProtoAdapter.BYTES_VALUE, -> return ByteStringJsonFormatter ProtoAdapter.DURATION -> return DurationJsonFormatter + ProtoAdapter.FIELD_MASK -> return FieldMaskJsonFormatter ProtoAdapter.INSTANT -> return InstantJsonFormatter is EnumAdapter<*> -> return EnumJsonFormatter(protoAdapter) } diff --git a/wire-tests/fixtures/shared/proto/proto3/contains_field_mask.proto b/wire-tests/fixtures/shared/proto/proto3/contains_field_mask.proto new file mode 100644 index 0000000000..77255e1c97 --- /dev/null +++ b/wire-tests/fixtures/shared/proto/proto3/contains_field_mask.proto @@ -0,0 +1,25 @@ +/* + * Copyright 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 + * + * http://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. + */ +syntax = "proto3"; + +package squareup.proto3; + +import "google/protobuf/field_mask.proto"; + +message ContainsFieldMask { + google.protobuf.FieldMask mask = 1; + repeated google.protobuf.FieldMask masks = 2; +} diff --git a/wire-tests/jvm-json-java/build.gradle.kts b/wire-tests/jvm-json-java/build.gradle.kts index f762b02fbb..6004f25bf2 100644 --- a/wire-tests/jvm-json-java/build.gradle.kts +++ b/wire-tests/jvm-json-java/build.gradle.kts @@ -37,6 +37,7 @@ wire { "all_structs.proto", "all_wrappers.proto", "camel_case.proto", + "contains_field_mask.proto", "map_types.proto", "pizza.proto", ) diff --git a/wire-tests/jvm-json-kotlin/build.gradle.kts b/wire-tests/jvm-json-kotlin/build.gradle.kts index af069fa235..e78ad67314 100644 --- a/wire-tests/jvm-json-kotlin/build.gradle.kts +++ b/wire-tests/jvm-json-kotlin/build.gradle.kts @@ -37,6 +37,7 @@ wire { "all_structs.proto", "all_wrappers.proto", "camel_case.proto", + "contains_field_mask.proto", "map_types.proto", "pizza.proto", ) 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 56ad435c15..c7fdffee22 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 @@ -45,6 +45,7 @@ import squareup.proto3.AllWrappers import squareup.proto3.BuyOneGetOnePromotion import squareup.proto3.CamelCase import squareup.proto3.CamelCase.NestedCamelCase +import squareup.proto3.ContainsFieldMask import squareup.proto3.FreeDrinkPromotion import squareup.proto3.FreeGarlicBreadPromotion import squareup.proto3.MapTypes @@ -267,6 +268,33 @@ class WireJsonTest { ) } + @Test fun fieldMask() { + val value = ContainsFieldMask.Builder() + .mask(FieldMask(listOf("user.display_name", "photo", "foo_bar.baz_qux"))) + .masks( + listOf( + FieldMask(listOf("display_name")), + FieldMask(listOf("updated_at.seconds")), + ), + ) + .build() + val json = """ + |{ + | "mask": "user.displayName,photo,fooBar.bazQux", + | "masks": ["displayName", "updatedAt.seconds"] + |} + """.trimMargin() + + assertJsonEquals(json, jsonLibrary.toJson(value, ContainsFieldMask::class.java)) + + val parsed = jsonLibrary.fromJson(json, ContainsFieldMask::class.java) + assertThat(parsed).isEqualTo(value) + assertJsonEquals( + jsonLibrary.toJson(parsed, ContainsFieldMask::class.java), + jsonLibrary.toJson(value, ContainsFieldMask::class.java), + ) + } + @Test fun anyMessageWithUnregisteredTypeOnReading() { try { jsonLibrary.fromJson(PIZZA_DELIVERY_UNKNOWN_TYPE_JSON, PizzaDelivery::class.java) diff --git a/wire-tests/wire-json-shared-kotlin-tests/com/squareup/wire/internal/FieldMaskJsonFormatterTest.kt b/wire-tests/wire-json-shared-kotlin-tests/com/squareup/wire/internal/FieldMaskJsonFormatterTest.kt new file mode 100644 index 0000000000..cb06797dc5 --- /dev/null +++ b/wire-tests/wire-json-shared-kotlin-tests/com/squareup/wire/internal/FieldMaskJsonFormatterTest.kt @@ -0,0 +1,45 @@ +/* + * 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.internal + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.squareup.wire.FieldMask +import com.squareup.wire.internal.FieldMaskJsonFormatter.fromString +import com.squareup.wire.internal.FieldMaskJsonFormatter.toStringOrNumber +import org.junit.Test + +class FieldMaskJsonFormatterTest { + @Test fun `field mask to string`() { + assertThat( + toStringOrNumber(FieldMask(listOf("user.display_name", "photo", "foo_bar.baz_qux"))), + ).isEqualTo("user.displayName,photo,fooBar.bazQux") + } + + @Test fun `string to field mask`() { + assertThat(fromString("user.displayName,photo,fooBar.bazQux")) + .isEqualTo(FieldMask(listOf("user.display_name", "photo", "foo_bar.baz_qux"))) + } + + @Test fun `empty paths are skipped`() { + assertThat(toStringOrNumber(FieldMask(listOf("", "photo", "")))) + .isEqualTo("photo") + assertThat(fromString(",photo,")) + .isEqualTo(FieldMask(listOf("photo"))) + assertThat(fromString("")) + .isEqualTo(FieldMask()) + } +}