|
| 1 | +From dc084a358eeaebbec93f529381851c5f7d48a4b1 Mon Sep 17 00:00:00 2001 |
| 2 | +From: Marius Volkhart <marius.volkhart@pkware.com> |
| 3 | +Date: Thu, 28 May 2026 11:52:21 -0400 |
| 4 | +Subject: [PATCH] Add Wire protobuf support |
| 5 | + |
| 6 | +WirePayloadConverter handles com.squareup.wire.Message types using |
| 7 | +binary encoding (protobuf/wire). Added to STANDARD_PAYLOAD_CONVERTERS |
| 8 | +between ProtobufPayloadConverter and MoshiJsonPayloadConverter. |
| 9 | + |
| 10 | +Wire's WireJsonAdapterFactory registered on MoshiJsonPayloadConverter |
| 11 | +so Wire types also work through JSON path. |
| 12 | + |
| 13 | +Adds wire-runtime and wire-moshi-adapter as api dependencies. |
| 14 | +Wire Gradle plugin compiles test .proto for WirePayloadConverterTest. |
| 15 | +--- |
| 16 | + build.gradle | 6 ++ |
| 17 | + temporal-sdk/build.gradle | 15 +++ |
| 18 | + .../converter/DefaultDataConverter.java | 1 + |
| 19 | + .../converter/MoshiJsonPayloadConverter.java | 3 + |
| 20 | + .../converter/WirePayloadConverter.java | 61 +++++++++++ |
| 21 | + .../converter/WirePayloadConverterTest.java | 100 ++++++++++++++++++ |
| 22 | + temporal-sdk/src/test/proto/test_wire.proto | 15 +++ |
| 23 | + 7 files changed, 201 insertions(+) |
| 24 | + create mode 100644 temporal-sdk/src/main/java/io/temporal/common/converter/WirePayloadConverter.java |
| 25 | + create mode 100644 temporal-sdk/src/test/java/io/temporal/common/converter/WirePayloadConverterTest.java |
| 26 | + create mode 100644 temporal-sdk/src/test/proto/test_wire.proto |
| 27 | + |
| 28 | +diff --git a/build.gradle b/build.gradle |
| 29 | +index 6dcbcc7..a304c21 100644 |
| 30 | +--- a/build.gradle |
| 31 | ++++ b/build.gradle |
| 32 | +@@ -1,3 +1,9 @@ |
| 33 | ++buildscript { |
| 34 | ++ dependencies { |
| 35 | ++ classpath "com.squareup.wire:wire-gradle-plugin:$wireVersion" |
| 36 | ++ } |
| 37 | ++} |
| 38 | ++ |
| 39 | + plugins { |
| 40 | + id 'net.ltgt.errorprone' version '4.1.0' apply false |
| 41 | + id 'io.github.gradle-nexus.publish-plugin' version '1.3.0' |
| 42 | +diff --git a/temporal-sdk/build.gradle b/temporal-sdk/build.gradle |
| 43 | +index ea02cdf..fc9da30 100644 |
| 44 | +--- a/temporal-sdk/build.gradle |
| 45 | ++++ b/temporal-sdk/build.gradle |
| 46 | +@@ -1,5 +1,18 @@ |
| 47 | ++apply plugin: 'com.squareup.wire' |
| 48 | ++ |
| 49 | + description = '''Temporal Workflow Java SDK''' |
| 50 | + |
| 51 | ++wire { |
| 52 | ++ sourcePath { |
| 53 | ++ srcDir 'src/test/proto' |
| 54 | ++ } |
| 55 | ++ java { |
| 56 | ++ out = "${buildDir}/generated/source/wire" |
| 57 | ++ } |
| 58 | ++} |
| 59 | ++ |
| 60 | ++sourceSets.test.java.srcDir("${buildDir}/generated/source/wire") |
| 61 | ++ |
| 62 | + dependencies { |
| 63 | + api(platform("io.grpc:grpc-bom:$grpcVersion")) |
| 64 | + api(platform("io.micrometer:micrometer-bom:$micrometerVersion")) |
| 65 | +@@ -10,6 +23,8 @@ dependencies { |
| 66 | + |
| 67 | + implementation "com.google.guava:guava:$guavaVersion" |
| 68 | + api "com.squareup.moshi:moshi:$moshiVersion" |
| 69 | ++ api "com.squareup.wire:wire-runtime:$wireVersion" |
| 70 | ++ api "com.squareup.wire:wire-moshi-adapter:$wireVersion" |
| 71 | + |
| 72 | + // compileOnly and testImplementation because this dependency is needed only to work with json format of history |
| 73 | + // which shouldn't be needed for any production usage of temporal-sdk. |
| 74 | +diff --git a/temporal-sdk/src/main/java/io/temporal/common/converter/DefaultDataConverter.java b/temporal-sdk/src/main/java/io/temporal/common/converter/DefaultDataConverter.java |
| 75 | +index d86e948..b8e0b11 100644 |
| 76 | +--- a/temporal-sdk/src/main/java/io/temporal/common/converter/DefaultDataConverter.java |
| 77 | ++++ b/temporal-sdk/src/main/java/io/temporal/common/converter/DefaultDataConverter.java |
| 78 | +@@ -18,6 +18,7 @@ public class DefaultDataConverter extends PayloadAndFailureDataConverter { |
| 79 | + new ByteArrayPayloadConverter(), |
| 80 | + new ProtobufJsonPayloadConverter(), |
| 81 | + new ProtobufPayloadConverter(), |
| 82 | ++ new WirePayloadConverter(), |
| 83 | + new MoshiJsonPayloadConverter() |
| 84 | + }; |
| 85 | + |
| 86 | +diff --git a/temporal-sdk/src/main/java/io/temporal/common/converter/MoshiJsonPayloadConverter.java b/temporal-sdk/src/main/java/io/temporal/common/converter/MoshiJsonPayloadConverter.java |
| 87 | +index f81922d..019e163 100644 |
| 88 | +--- a/temporal-sdk/src/main/java/io/temporal/common/converter/MoshiJsonPayloadConverter.java |
| 89 | ++++ b/temporal-sdk/src/main/java/io/temporal/common/converter/MoshiJsonPayloadConverter.java |
| 90 | +@@ -3,6 +3,7 @@ package io.temporal.common.converter; |
| 91 | + import com.google.protobuf.ByteString; |
| 92 | + import com.squareup.moshi.JsonAdapter; |
| 93 | + import com.squareup.moshi.Moshi; |
| 94 | ++import com.squareup.wire.WireJsonAdapterFactory; |
| 95 | + import io.temporal.api.common.v1.Payload; |
| 96 | + import java.io.IOException; |
| 97 | + import java.lang.reflect.Type; |
| 98 | +@@ -29,6 +30,7 @@ public class MoshiJsonPayloadConverter implements PayloadConverter { |
| 99 | + this.moshi = |
| 100 | + userMoshi |
| 101 | + .newBuilder() |
| 102 | ++ .add(new WireJsonAdapterFactory()) |
| 103 | + .add(new DurationMillisAdapter()) |
| 104 | + .add(new OperationTokenTypeAdapter()) |
| 105 | + .build(); |
| 106 | +@@ -41,6 +43,7 @@ public class MoshiJsonPayloadConverter implements PayloadConverter { |
| 107 | + */ |
| 108 | + public static Moshi newDefaultMoshi() { |
| 109 | + return new Moshi.Builder() |
| 110 | ++ .add(new WireJsonAdapterFactory()) |
| 111 | + .add(new DurationMillisAdapter()) |
| 112 | + .add(new OperationTokenTypeAdapter()) |
| 113 | + .build(); |
| 114 | +diff --git a/temporal-sdk/src/main/java/io/temporal/common/converter/WirePayloadConverter.java b/temporal-sdk/src/main/java/io/temporal/common/converter/WirePayloadConverter.java |
| 115 | +new file mode 100644 |
| 116 | +index 0000000..82c8a24 |
| 117 | +--- /dev/null |
| 118 | ++++ b/temporal-sdk/src/main/java/io/temporal/common/converter/WirePayloadConverter.java |
| 119 | +@@ -0,0 +1,61 @@ |
| 120 | ++package io.temporal.common.converter; |
| 121 | ++ |
| 122 | ++import com.google.protobuf.ByteString; |
| 123 | ++import com.squareup.wire.Message; |
| 124 | ++import com.squareup.wire.ProtoAdapter; |
| 125 | ++import io.temporal.api.common.v1.Payload; |
| 126 | ++import java.io.IOException; |
| 127 | ++import java.lang.reflect.Type; |
| 128 | ++import java.nio.charset.StandardCharsets; |
| 129 | ++import java.util.Optional; |
| 130 | ++ |
| 131 | ++/** |
| 132 | ++ * {@link PayloadConverter} for Wire-generated protobuf types. |
| 133 | ++ * |
| 134 | ++ * <p>Uses Wire's native binary encoding via each message's companion {@link ProtoAdapter}. Non-Wire |
| 135 | ++ * types are skipped (returns {@link Optional#empty()}) so the converter chain falls through to the |
| 136 | ++ * next converter. |
| 137 | ++ */ |
| 138 | ++public class WirePayloadConverter implements PayloadConverter { |
| 139 | ++ private static final String ENCODING_TYPE = "protobuf/wire"; |
| 140 | ++ private static final ByteString ENCODING_METADATA = |
| 141 | ++ ByteString.copyFrom(ENCODING_TYPE, StandardCharsets.UTF_8); |
| 142 | ++ |
| 143 | ++ @Override |
| 144 | ++ public String getEncodingType() { |
| 145 | ++ return ENCODING_TYPE; |
| 146 | ++ } |
| 147 | ++ |
| 148 | ++ @Override |
| 149 | ++ @SuppressWarnings({"unchecked", "rawtypes"}) |
| 150 | ++ public Optional<Payload> toData(Object value) throws DataConverterException { |
| 151 | ++ if (value == null || !(value instanceof Message)) return Optional.empty(); |
| 152 | ++ try { |
| 153 | ++ ProtoAdapter adapter = (ProtoAdapter) value.getClass().getField("ADAPTER").get(null); |
| 154 | ++ byte[] bytes = adapter.encode(value); |
| 155 | ++ return Optional.of( |
| 156 | ++ Payload.newBuilder() |
| 157 | ++ .putMetadata(EncodingKeys.METADATA_ENCODING_KEY, ENCODING_METADATA) |
| 158 | ++ .setData(ByteString.copyFrom(bytes)) |
| 159 | ++ .build()); |
| 160 | ++ } catch (NoSuchFieldException | IllegalAccessException e) { |
| 161 | ++ throw new DataConverterException( |
| 162 | ++ "Class " + value.getClass().getName() + " is not a Wire-generated message", e); |
| 163 | ++ } |
| 164 | ++ } |
| 165 | ++ |
| 166 | ++ @Override |
| 167 | ++ @SuppressWarnings("unchecked") |
| 168 | ++ public <T> T fromData(Payload content, Class<T> valueClass, Type valueType) |
| 169 | ++ throws DataConverterException { |
| 170 | ++ try { |
| 171 | ++ ProtoAdapter<T> adapter = (ProtoAdapter<T>) valueClass.getField("ADAPTER").get(null); |
| 172 | ++ return adapter.decode(content.getData().toByteArray()); |
| 173 | ++ } catch (IOException e) { |
| 174 | ++ throw new DataConverterException(e); |
| 175 | ++ } catch (NoSuchFieldException | IllegalAccessException e) { |
| 176 | ++ throw new DataConverterException( |
| 177 | ++ "Class " + valueClass.getName() + " is not a Wire-generated message", e); |
| 178 | ++ } |
| 179 | ++ } |
| 180 | ++} |
| 181 | +diff --git a/temporal-sdk/src/test/java/io/temporal/common/converter/WirePayloadConverterTest.java b/temporal-sdk/src/test/java/io/temporal/common/converter/WirePayloadConverterTest.java |
| 182 | +new file mode 100644 |
| 183 | +index 0000000..3fe57cb |
| 184 | +--- /dev/null |
| 185 | ++++ b/temporal-sdk/src/test/java/io/temporal/common/converter/WirePayloadConverterTest.java |
| 186 | +@@ -0,0 +1,100 @@ |
| 187 | ++package io.temporal.common.converter; |
| 188 | ++ |
| 189 | ++import static org.junit.Assert.*; |
| 190 | ++ |
| 191 | ++import com.google.protobuf.ByteString; |
| 192 | ++import io.temporal.api.common.v1.Payload; |
| 193 | ++import io.temporal.common.converter.wiretest.NestedMessage; |
| 194 | ++import io.temporal.common.converter.wiretest.SimpleMessage; |
| 195 | ++import java.nio.charset.StandardCharsets; |
| 196 | ++import java.util.Arrays; |
| 197 | ++import java.util.Optional; |
| 198 | ++import org.junit.Test; |
| 199 | ++ |
| 200 | ++public class WirePayloadConverterTest { |
| 201 | ++ |
| 202 | ++ private final WirePayloadConverter converter = new WirePayloadConverter(); |
| 203 | ++ |
| 204 | ++ @Test |
| 205 | ++ public void encodingTypeIsProtobufWire() { |
| 206 | ++ assertEquals("protobuf/wire", converter.getEncodingType()); |
| 207 | ++ } |
| 208 | ++ |
| 209 | ++ @Test |
| 210 | ++ public void nullReturnsEmpty() { |
| 211 | ++ assertFalse(converter.toData(null).isPresent()); |
| 212 | ++ } |
| 213 | ++ |
| 214 | ++ @Test |
| 215 | ++ public void nonWireTypeReturnsEmpty() { |
| 216 | ++ assertFalse(converter.toData("just a string").isPresent()); |
| 217 | ++ } |
| 218 | ++ |
| 219 | ++ @Test |
| 220 | ++ public void serializesWireMessage() { |
| 221 | ++ SimpleMessage msg = new SimpleMessage.Builder().name("Alice").value(30).build(); |
| 222 | ++ |
| 223 | ++ Optional<Payload> result = converter.toData(msg); |
| 224 | ++ |
| 225 | ++ assertTrue(result.isPresent()); |
| 226 | ++ String encoding = |
| 227 | ++ result |
| 228 | ++ .get() |
| 229 | ++ .getMetadataMap() |
| 230 | ++ .get(EncodingKeys.METADATA_ENCODING_KEY) |
| 231 | ++ .toString(StandardCharsets.UTF_8); |
| 232 | ++ assertEquals("protobuf/wire", encoding); |
| 233 | ++ assertFalse(result.get().getData().isEmpty()); |
| 234 | ++ } |
| 235 | ++ |
| 236 | ++ @Test |
| 237 | ++ public void roundTripsSimpleMessage() { |
| 238 | ++ SimpleMessage original = new SimpleMessage.Builder().name("Bob").value(42).build(); |
| 239 | ++ |
| 240 | ++ Payload payload = converter.toData(original).get(); |
| 241 | ++ SimpleMessage restored = converter.fromData(payload, SimpleMessage.class, SimpleMessage.class); |
| 242 | ++ |
| 243 | ++ assertEquals(original, restored); |
| 244 | ++ } |
| 245 | ++ |
| 246 | ++ @Test |
| 247 | ++ public void roundTripsNestedMessage() { |
| 248 | ++ NestedMessage original = |
| 249 | ++ new NestedMessage.Builder() |
| 250 | ++ .id("test-123") |
| 251 | ++ .items( |
| 252 | ++ Arrays.asList( |
| 253 | ++ new SimpleMessage.Builder().name("a").value(1).build(), |
| 254 | ++ new SimpleMessage.Builder().name("b").value(2).build())) |
| 255 | ++ .build(); |
| 256 | ++ |
| 257 | ++ Payload payload = converter.toData(original).get(); |
| 258 | ++ NestedMessage restored = converter.fromData(payload, NestedMessage.class, NestedMessage.class); |
| 259 | ++ |
| 260 | ++ assertEquals(original, restored); |
| 261 | ++ } |
| 262 | ++ |
| 263 | ++ @Test |
| 264 | ++ public void decodesEmptyPayloadAsIdentityMessage() { |
| 265 | ++ Payload payload = wirePayload(new byte[0]); |
| 266 | ++ |
| 267 | ++ SimpleMessage result = converter.fromData(payload, SimpleMessage.class, SimpleMessage.class); |
| 268 | ++ |
| 269 | ++ assertEquals(new SimpleMessage.Builder().build(), result); |
| 270 | ++ } |
| 271 | ++ |
| 272 | ++ @Test(expected = DataConverterException.class) |
| 273 | ++ public void corruptedBinaryThrows() { |
| 274 | ++ Payload payload = wirePayload(new byte[] {(byte) 0xFF, (byte) 0xFE, 0x01, 0x02, 0x03}); |
| 275 | ++ converter.fromData(payload, SimpleMessage.class, SimpleMessage.class); |
| 276 | ++ } |
| 277 | ++ |
| 278 | ++ private static Payload wirePayload(byte[] bytes) { |
| 279 | ++ return Payload.newBuilder() |
| 280 | ++ .putMetadata( |
| 281 | ++ EncodingKeys.METADATA_ENCODING_KEY, |
| 282 | ++ ByteString.copyFrom("protobuf/wire", StandardCharsets.UTF_8)) |
| 283 | ++ .setData(ByteString.copyFrom(bytes)) |
| 284 | ++ .build(); |
| 285 | ++ } |
| 286 | ++} |
| 287 | +diff --git a/temporal-sdk/src/test/proto/test_wire.proto b/temporal-sdk/src/test/proto/test_wire.proto |
| 288 | +new file mode 100644 |
| 289 | +index 0000000..d08747e |
| 290 | +--- /dev/null |
| 291 | ++++ b/temporal-sdk/src/test/proto/test_wire.proto |
| 292 | +@@ -0,0 +1,15 @@ |
| 293 | ++syntax = "proto3"; |
| 294 | ++ |
| 295 | ++package io.temporal.common.converter.wiretest; |
| 296 | ++ |
| 297 | ++option java_package = "io.temporal.common.converter.wiretest"; |
| 298 | ++ |
| 299 | ++message SimpleMessage { |
| 300 | ++ string name = 1; |
| 301 | ++ int32 value = 2; |
| 302 | ++} |
| 303 | ++ |
| 304 | ++message NestedMessage { |
| 305 | ++ string id = 1; |
| 306 | ++ repeated SimpleMessage items = 2; |
| 307 | ++} |
| 308 | +-- |
| 309 | +2.50.1 (Apple Git-155) |
| 310 | + |
0 commit comments