From b498f128ce1d58eea0c709edc48f7143165119ee Mon Sep 17 00:00:00 2001 From: Joshua Frenchwood Date: Tue, 29 Sep 2026 12:28:56 -0500 Subject: [PATCH 1/8] Sample for NexusSerializationContext --- build.gradle | 4 +- .../nexusserializationcontext/NexusCodec.java | 196 ++++++++++++++++++ .../NexusEncoding.java | 17 ++ .../nexusserializationcontext/README.md | 63 ++++++ .../SampleConfig.java | 21 ++ .../caller/CallerStarter.java | 24 +++ .../caller/CallerWorker.java | 21 ++ .../caller/CallerWorkflow.java | 10 + .../caller/CallerWorkflowImpl.java | 36 ++++ .../caller/EndpointResults.java | 3 + .../handler/EchoServiceImpl.java | 14 ++ .../handler/HandlerWorker.java | 26 +++ .../service/EchoService.java | 13 ++ 13 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusCodec.java create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusEncoding.java create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerStarter.java create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorker.java create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflow.java create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflowImpl.java create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/EndpointResults.java create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EchoServiceImpl.java create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/HandlerWorker.java create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/service/EchoService.java diff --git a/build.gradle b/build.gradle index 98bf5158..20b88e92 100644 --- a/build.gradle +++ b/build.gradle @@ -21,7 +21,7 @@ subprojects { ext { otelVersion = '1.30.1' otelVersionAlpha = "${otelVersion}-alpha" - javaSDKVersion = '1.39.0' + javaSDKVersion = '1.40.0' camelVersion = '3.22.1' jarVersion = '1.0.0' } @@ -49,4 +49,4 @@ subprojects { test { useJUnitPlatform() } -} \ No newline at end of file +} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusCodec.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusCodec.java new file mode 100644 index 00000000..e38a5c37 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusCodec.java @@ -0,0 +1,196 @@ +package io.temporal.samples.nexusserializationcontext; + +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; +import io.temporal.api.common.v1.Payload; +import io.temporal.common.converter.EncodingKeys; +import io.temporal.payload.codec.PayloadCodec; +import io.temporal.payload.codec.PayloadCodecException; +import io.temporal.payload.context.NexusSerializationContext; +import io.temporal.payload.context.SerializationContext; +import io.temporal.samples.nexusserializationcontext.service.EchoService; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.InflaterInputStream; +import javax.annotation.Nonnull; +import javax.crypto.Mac; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +/** Selects a payload encoding from the Nexus endpoint, service, and operation. */ +public final class NexusCodec implements PayloadCodec { + private static final SecretKey HMAC_KEY = + new SecretKeySpec( + "nexus-serialization-sample-key".getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + private static final int HMAC_LENGTH = 32; + + private final NexusEncoding selectedEncoding; + + public NexusCodec() { + this.selectedEncoding = NexusEncoding.NONE; + } + + private NexusCodec(NexusEncoding selectedEncoding) { + this.selectedEncoding = selectedEncoding; + } + + @Override + @Nonnull + public PayloadCodec withContext(@Nonnull SerializationContext context) { + if (!(context instanceof NexusSerializationContext)) { + return new NexusCodec(); + } + NexusSerializationContext nexusSerializationContext = (NexusSerializationContext) context; + if (!EchoService.SERVICE_NAME.equals(nexusSerializationContext.getService()) + || !EchoService.ECHO_OPERATION_NAME.equals(nexusSerializationContext.getOperation())) { + throw new PayloadCodecException( + "Unexpected Nexus service or operation: " + nexusSerializationContext); + } + if (SampleConfig.HMAC_ENDPOINT.equals(nexusSerializationContext.getEndpoint())) { + return new NexusCodec(NexusEncoding.HMAC); + } + if (SampleConfig.ZLIB_ENDPOINT.equals(nexusSerializationContext.getEndpoint())) { + return new NexusCodec(NexusEncoding.ZLIB); + } + throw new PayloadCodecException( + "Unknown Nexus endpoint: " + nexusSerializationContext.getEndpoint()); + } + + @Override + @Nonnull + public List encode(@Nonnull List payloads) { + return switch (selectedEncoding) { + case HMAC -> encodeHmac(payloads); + case ZLIB -> encodeZlib(payloads); + case NONE -> payloads; + }; + } + + @Override + @Nonnull + public List decode(@Nonnull List payloads) { + return switch (selectedEncoding) { + case HMAC -> decodeHmac(payloads); + case ZLIB -> decodeZlib(payloads); + case NONE -> payloads; + }; + } + + private static List encodeHmac(List payloads) { + List encoded = new ArrayList<>(payloads.size()); + for (Payload payload : payloads) { + byte[] bytes = payload.toByteArray(); + byte[] signature = hmac(bytes); + byte[] signed = + ByteBuffer.allocate(signature.length + bytes.length).put(signature).put(bytes).array(); + encoded.add( + Payload.newBuilder() + .putMetadata( + EncodingKeys.METADATA_ENCODING_KEY, + ByteString.copyFromUtf8(NexusEncoding.HMAC.encodingName())) + .setData(ByteString.copyFrom(signed)) + .build()); + } + return encoded; + } + + private static List decodeHmac(List payloads) { + List decoded = new ArrayList<>(payloads.size()); + for (Payload payload : payloads) { + String encoding = + payload + .getMetadataOrDefault(EncodingKeys.METADATA_ENCODING_KEY, ByteString.EMPTY) + .toStringUtf8(); + if (!NexusEncoding.HMAC.encodingName().equals(encoding)) { + throw new PayloadCodecException("Expected a Nexus HMAC payload"); + } + byte[] signed = payload.getData().toByteArray(); + if (signed.length < HMAC_LENGTH) { + throw new PayloadCodecException("Nexus HMAC payload is too short"); + } + byte[] signature = Arrays.copyOfRange(signed, 0, HMAC_LENGTH); + byte[] bytes = Arrays.copyOfRange(signed, HMAC_LENGTH, signed.length); + if (!MessageDigest.isEqual(signature, hmac(bytes))) { + throw new PayloadCodecException("Nexus HMAC does not match"); + } + try { + decoded.add(Payload.parseFrom(bytes)); + } catch (InvalidProtocolBufferException e) { + throw new PayloadCodecException(e); + } + } + return decoded; + } + + private static List encodeZlib(List payloads) { + List encoded = new ArrayList<>(payloads.size()); + for (Payload payload : payloads) { + encoded.add( + Payload.newBuilder() + .putMetadata( + EncodingKeys.METADATA_ENCODING_KEY, + ByteString.copyFromUtf8(NexusEncoding.ZLIB.encodingName())) + .setData(ByteString.copyFrom(compress(payload.toByteArray()))) + .build()); + } + return encoded; + } + + private static List decodeZlib(List payloads) { + List decoded = new ArrayList<>(payloads.size()); + for (Payload payload : payloads) { + String encoding = + payload + .getMetadataOrDefault(EncodingKeys.METADATA_ENCODING_KEY, ByteString.EMPTY) + .toStringUtf8(); + if (!NexusEncoding.ZLIB.encodingName().equals(encoding)) { + throw new PayloadCodecException("Expected a Nexus zlib payload"); + } + try { + decoded.add(Payload.parseFrom(decompress(payload.getData().toByteArray()))); + } catch (InvalidProtocolBufferException e) { + throw new PayloadCodecException(e); + } + } + return decoded; + } + + private static byte[] hmac(byte[] bytes) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(HMAC_KEY); + return mac.doFinal(bytes); + } catch (GeneralSecurityException e) { + throw new PayloadCodecException(e); + } + } + + private static byte[] compress(byte[] bytes) { + try { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (DeflaterOutputStream deflater = new DeflaterOutputStream(output)) { + deflater.write(bytes); + } + return output.toByteArray(); + } catch (IOException e) { + throw new PayloadCodecException(e); + } + } + + private static byte[] decompress(byte[] bytes) { + try (InflaterInputStream inflater = new InflaterInputStream(new ByteArrayInputStream(bytes))) { + return inflater.readAllBytes(); + } catch (IOException e) { + throw new PayloadCodecException(e); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusEncoding.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusEncoding.java new file mode 100644 index 00000000..49c66b57 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusEncoding.java @@ -0,0 +1,17 @@ +package io.temporal.samples.nexusserializationcontext; + +enum NexusEncoding { + NONE(""), + HMAC("binary/nexus-hmac"), + ZLIB("binary/nexus-zlib"); + + private final String encodingName; + + NexusEncoding(String encodingName) { + this.encodingName = encodingName; + } + + String encodingName() { + return encodingName; + } +} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md b/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md new file mode 100644 index 00000000..39663283 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md @@ -0,0 +1,63 @@ +# Nexus serialization context + +This sample calls the same synchronous Nexus operation through two endpoints. A +`PayloadCodec` uses `NexusSerializationContext` to choose HMAC for one endpoint and +zlib compression for the other. The caller schedules both operations before waiting +for their results, so each result must be decoded using the context of its own +endpoint. Non-Nexus payloads pass through the codec unchanged. + +`NexusSerializationContext` works end to end for synchronous Nexus operations. +The final result of an asynchronous operation does not receive `NexusSerializationContext`. + +HMAC authenticates payloads but does not encrypt them. The hard-coded key is only +for this local example. For production encryption, use a secure key store and a +codec such as the [AWS Encryption SDK sample](../keymanagementencryption/awsencryptionsdk/README.md). + +Requires Java SDK 1.40.0 or later and a Temporal server with Nexus enabled. + +## Run locally + +Start a Temporal dev server: + +```bash +temporal server start-dev +``` + +In another terminal, create the namespaces and endpoints: + +```bash +temporal operator namespace create --namespace nexus-serialization-handler +temporal operator namespace create --namespace nexus-serialization-caller +temporal operator nexus endpoint create \ + --name nexus-serialization-hmac \ + --target-namespace nexus-serialization-handler \ + --target-task-queue nexus-serialization-hmac-handler +temporal operator nexus endpoint create \ + --name nexus-serialization-zlib \ + --target-namespace nexus-serialization-handler \ + --target-task-queue nexus-serialization-zlib-handler +``` + +Run each of the following in its own terminal from the repository root: + +```bash +./gradlew -q :core:execute -PmainClass=io.temporal.samples.nexusserializationcontext.handler.HandlerWorker \ + --args="-namespace nexus-serialization-handler" +``` + +```bash +./gradlew -q :core:execute -PmainClass=io.temporal.samples.nexusserializationcontext.caller.CallerWorker \ + --args="-namespace nexus-serialization-caller" +``` + +```bash +./gradlew -q :core:execute -PmainClass=io.temporal.samples.nexusserializationcontext.caller.CallerStarter \ + --args="-namespace nexus-serialization-caller" +``` + +The starter prints: + +```text +HMAC endpoint result: Hello from Nexus +zlib endpoint result: Hello from Nexus +``` diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java new file mode 100644 index 00000000..e6267d96 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java @@ -0,0 +1,21 @@ +package io.temporal.samples.nexusserializationcontext; + +import io.temporal.common.converter.CodecDataConverter; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DefaultDataConverter; +import java.util.Collections; + +public final class SampleConfig { + public static final String HMAC_ENDPOINT = "nexus-serialization-hmac"; + public static final String ZLIB_ENDPOINT = "nexus-serialization-zlib"; + public static final String HMAC_HANDLER_TASK_QUEUE = "nexus-serialization-hmac-handler"; + public static final String ZLIB_HANDLER_TASK_QUEUE = "nexus-serialization-zlib-handler"; + public static final String CALLER_TASK_QUEUE = "nexus-serialization-caller"; + + private SampleConfig() {} + + public static DataConverter dataConverter() { + return new CodecDataConverter( + DefaultDataConverter.newDefaultInstance(), Collections.singletonList(new NexusCodec())); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerStarter.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerStarter.java new file mode 100644 index 00000000..a6e01949 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerStarter.java @@ -0,0 +1,24 @@ +package io.temporal.samples.nexusserializationcontext.caller; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.client.WorkflowOptions; +import io.temporal.samples.nexus.options.ClientOptions; +import io.temporal.samples.nexusserializationcontext.SampleConfig; + +public class CallerStarter { + public static void main(String[] args) { + WorkflowClient client = + ClientOptions.getWorkflowClient( + args, + WorkflowClientOptions.newBuilder().setDataConverter(SampleConfig.dataConverter())); + CallerWorkflow workflow = + client.newWorkflowStub( + CallerWorkflow.class, + WorkflowOptions.newBuilder().setTaskQueue(SampleConfig.CALLER_TASK_QUEUE).build()); + + EndpointResults results = workflow.callBoth("Hello from Nexus"); + System.out.println("HMAC endpoint result: " + results.hmacResult()); + System.out.println("zlib endpoint result: " + results.zlibResult()); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorker.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorker.java new file mode 100644 index 00000000..a7d8e93f --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorker.java @@ -0,0 +1,21 @@ +package io.temporal.samples.nexusserializationcontext.caller; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.samples.nexus.options.ClientOptions; +import io.temporal.samples.nexusserializationcontext.SampleConfig; +import io.temporal.worker.Worker; +import io.temporal.worker.WorkerFactory; + +public class CallerWorker { + public static void main(String[] args) { + WorkflowClient client = + ClientOptions.getWorkflowClient( + args, + WorkflowClientOptions.newBuilder().setDataConverter(SampleConfig.dataConverter())); + WorkerFactory factory = WorkerFactory.newInstance(client); + Worker worker = factory.newWorker(SampleConfig.CALLER_TASK_QUEUE); + worker.registerWorkflowImplementationTypes(CallerWorkflowImpl.class); + factory.start(); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflow.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflow.java new file mode 100644 index 00000000..5f5ffc4a --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflow.java @@ -0,0 +1,10 @@ +package io.temporal.samples.nexusserializationcontext.caller; + +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +@WorkflowInterface +public interface CallerWorkflow { + @WorkflowMethod + EndpointResults callBoth(String message); +} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflowImpl.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflowImpl.java new file mode 100644 index 00000000..55f97cf7 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflowImpl.java @@ -0,0 +1,36 @@ +package io.temporal.samples.nexusserializationcontext.caller; + +import io.temporal.samples.nexusserializationcontext.SampleConfig; +import io.temporal.samples.nexusserializationcontext.service.EchoService; +import io.temporal.workflow.NexusOperationHandle; +import io.temporal.workflow.NexusOperationOptions; +import io.temporal.workflow.NexusServiceOptions; +import io.temporal.workflow.Workflow; +import java.time.Duration; + +public class CallerWorkflowImpl implements CallerWorkflow { + @Override + public EndpointResults callBoth(String message) { + EchoService hmacService = serviceFor(SampleConfig.HMAC_ENDPOINT); + EchoService zlibService = serviceFor(SampleConfig.ZLIB_ENDPOINT); + + // Start both before awaiting either result. Each result must keep its own endpoint context. + NexusOperationHandle hmacOperation = + Workflow.startNexusOperation(hmacService::echo, message); + NexusOperationHandle zlibOperation = + Workflow.startNexusOperation(zlibService::echo, message); + return new EndpointResults(hmacOperation.getResult().get(), zlibOperation.getResult().get()); + } + + private static EchoService serviceFor(String endpoint) { + return Workflow.newNexusServiceStub( + EchoService.class, + NexusServiceOptions.newBuilder() + .setEndpoint(endpoint) + .setOperationOptions( + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build()) + .build()); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/EndpointResults.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/EndpointResults.java new file mode 100644 index 00000000..8bbb61d9 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/EndpointResults.java @@ -0,0 +1,3 @@ +package io.temporal.samples.nexusserializationcontext.caller; + +public record EndpointResults(String hmacResult, String zlibResult) {} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EchoServiceImpl.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EchoServiceImpl.java new file mode 100644 index 00000000..edab13d3 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EchoServiceImpl.java @@ -0,0 +1,14 @@ +package io.temporal.samples.nexusserializationcontext.handler; + +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.samples.nexusserializationcontext.service.EchoService; + +@ServiceImpl(service = EchoService.class) +public class EchoServiceImpl { + @OperationImpl + public OperationHandler echo() { + return OperationHandler.sync((ctx, details, message) -> message); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/HandlerWorker.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/HandlerWorker.java new file mode 100644 index 00000000..82f051e8 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/HandlerWorker.java @@ -0,0 +1,26 @@ +package io.temporal.samples.nexusserializationcontext.handler; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.samples.nexus.options.ClientOptions; +import io.temporal.samples.nexusserializationcontext.SampleConfig; +import io.temporal.worker.Worker; +import io.temporal.worker.WorkerFactory; + +public class HandlerWorker { + public static void main(String[] args) { + WorkflowClient client = + ClientOptions.getWorkflowClient( + args, + WorkflowClientOptions.newBuilder().setDataConverter(SampleConfig.dataConverter())); + WorkerFactory factory = WorkerFactory.newInstance(client); + + Worker hmacWorker = factory.newWorker(SampleConfig.HMAC_HANDLER_TASK_QUEUE); + hmacWorker.registerNexusServiceImplementation(new EchoServiceImpl()); + + Worker zlibWorker = factory.newWorker(SampleConfig.ZLIB_HANDLER_TASK_QUEUE); + zlibWorker.registerNexusServiceImplementation(new EchoServiceImpl()); + + factory.start(); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/service/EchoService.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/service/EchoService.java new file mode 100644 index 00000000..f389d6e4 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/service/EchoService.java @@ -0,0 +1,13 @@ +package io.temporal.samples.nexusserializationcontext.service; + +import io.nexusrpc.Operation; +import io.nexusrpc.Service; + +@Service(name = EchoService.SERVICE_NAME) +public interface EchoService { + String SERVICE_NAME = "EchoService"; + String ECHO_OPERATION_NAME = "echo"; + + @Operation(name = ECHO_OPERATION_NAME) + String echo(String message); +} From 09927cdea3cbe2b5ee6648a3ead5ee7783b2084c Mon Sep 17 00:00:00 2001 From: Joshua Frenchwood Date: Tue, 29 Sep 2026 15:29:27 -0500 Subject: [PATCH 2/8] Updating to use encryption and compressions on two different endpoints --- .../AesGcmCodec.java | 112 +++++++++++ .../nexusserializationcontext/NexusCodec.java | 177 ++---------------- .../NexusEncoding.java | 3 +- .../nexusserializationcontext/README.md | 36 ++-- .../SampleConfig.java | 33 +++- .../nexusserializationcontext/ZlibCodec.java | 76 ++++++++ .../caller/CallerStarter.java | 7 +- .../caller/CallerWorkflow.java | 2 +- .../caller/CallerWorkflowImpl.java | 17 +- .../caller/EndpointResults.java | 2 +- .../handler/HandlerWorker.java | 7 +- .../NexusCodecTest.java | 94 ++++++++++ 12 files changed, 362 insertions(+), 204 deletions(-) create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/AesGcmCodec.java create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/ZlibCodec.java create mode 100644 core/src/test/java/io/temporal/samples/nexusserializationcontext/NexusCodecTest.java diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/AesGcmCodec.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/AesGcmCodec.java new file mode 100644 index 00000000..cdd4a140 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/AesGcmCodec.java @@ -0,0 +1,112 @@ +package io.temporal.samples.nexusserializationcontext; + +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; +import io.temporal.api.common.v1.Payload; +import io.temporal.common.converter.EncodingKeys; +import io.temporal.payload.codec.PayloadCodec; +import io.temporal.payload.codec.PayloadCodecException; +import java.nio.ByteBuffer; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; + +/** Encrypts Nexus payloads with the key assigned to an endpoint. */ +final class AesGcmCodec implements PayloadCodec { + static final String KEY_ID_METADATA_KEY = "encryption-key-id"; + + private static final String CIPHER = "AES/GCM/NoPadding"; + private static final int NONCE_LENGTH = 12; + private static final int TAG_LENGTH_BITS = 128; + private static final SecureRandom RANDOM = new SecureRandom(); + + private final String keyId; + private final SecretKey key; + + AesGcmCodec(String keyId, SecretKey key) { + this.keyId = keyId; + this.key = key; + } + + @Override + @Nonnull + public List encode(@Nonnull List payloads) { + List encoded = new ArrayList<>(payloads.size()); + for (Payload payload : payloads) { + encoded.add( + Payload.newBuilder() + .putMetadata( + EncodingKeys.METADATA_ENCODING_KEY, + ByteString.copyFromUtf8(NexusEncoding.AES_GCM.encodingName())) + .putMetadata(KEY_ID_METADATA_KEY, ByteString.copyFromUtf8(keyId)) + .setData(ByteString.copyFrom(encrypt(payload.toByteArray()))) + .build()); + } + return encoded; + } + + @Override + @Nonnull + public List decode(@Nonnull List payloads) { + List decoded = new ArrayList<>(payloads.size()); + for (Payload payload : payloads) { + String encoding = + payload + .getMetadataOrDefault(EncodingKeys.METADATA_ENCODING_KEY, ByteString.EMPTY) + .toStringUtf8(); + if (!NexusEncoding.AES_GCM.encodingName().equals(encoding)) { + throw new PayloadCodecException("Expected a Nexus AES-GCM payload"); + } + String payloadKeyId = + payload.getMetadataOrDefault(KEY_ID_METADATA_KEY, ByteString.EMPTY).toStringUtf8(); + if (!keyId.equals(payloadKeyId)) { + throw new PayloadCodecException("Unexpected encryption key ID: " + payloadKeyId); + } + try { + decoded.add(Payload.parseFrom(decrypt(payload.getData().toByteArray()))); + } catch (InvalidProtocolBufferException e) { + throw new PayloadCodecException(e); + } + } + return decoded; + } + + private byte[] encrypt(byte[] bytes) { + byte[] nonce = new byte[NONCE_LENGTH]; + RANDOM.nextBytes(nonce); + try { + Cipher cipher = Cipher.getInstance(CIPHER); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BITS, nonce)); + byte[] ciphertext = cipher.doFinal(bytes); + return ByteBuffer.allocate(nonce.length + ciphertext.length) + .put(nonce) + .put(ciphertext) + .array(); + } catch (GeneralSecurityException e) { + throw new PayloadCodecException(e); + } + } + + private byte[] decrypt(byte[] encrypted) { + if (encrypted.length < NONCE_LENGTH + TAG_LENGTH_BITS / Byte.SIZE) { + throw new PayloadCodecException("Nexus AES-GCM payload is too short"); + } + ByteBuffer buffer = ByteBuffer.wrap(encrypted); + byte[] nonce = new byte[NONCE_LENGTH]; + buffer.get(nonce); + byte[] ciphertext = new byte[buffer.remaining()]; + buffer.get(ciphertext); + try { + Cipher cipher = Cipher.getInstance(CIPHER); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BITS, nonce)); + return cipher.doFinal(ciphertext); + } catch (GeneralSecurityException e) { + throw new PayloadCodecException(e); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusCodec.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusCodec.java index e38a5c37..d94d2fa2 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusCodec.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusCodec.java @@ -1,196 +1,45 @@ package io.temporal.samples.nexusserializationcontext; -import com.google.protobuf.ByteString; -import com.google.protobuf.InvalidProtocolBufferException; import io.temporal.api.common.v1.Payload; -import io.temporal.common.converter.EncodingKeys; import io.temporal.payload.codec.PayloadCodec; import io.temporal.payload.codec.PayloadCodecException; import io.temporal.payload.context.NexusSerializationContext; import io.temporal.payload.context.SerializationContext; -import io.temporal.samples.nexusserializationcontext.service.EchoService; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.security.GeneralSecurityException; -import java.security.MessageDigest; -import java.util.ArrayList; -import java.util.Arrays; import java.util.List; -import java.util.zip.DeflaterOutputStream; -import java.util.zip.InflaterInputStream; +import java.util.Map; import javax.annotation.Nonnull; -import javax.crypto.Mac; -import javax.crypto.SecretKey; -import javax.crypto.spec.SecretKeySpec; -/** Selects a payload encoding from the Nexus endpoint, service, and operation. */ +/** Selects a payload codec from the Nexus endpoint. */ public final class NexusCodec implements PayloadCodec { - private static final SecretKey HMAC_KEY = - new SecretKeySpec( - "nexus-serialization-sample-key".getBytes(StandardCharsets.UTF_8), "HmacSHA256"); - private static final int HMAC_LENGTH = 32; + private final Map codecsByEndpoint; - private final NexusEncoding selectedEncoding; - - public NexusCodec() { - this.selectedEncoding = NexusEncoding.NONE; - } - - private NexusCodec(NexusEncoding selectedEncoding) { - this.selectedEncoding = selectedEncoding; + public NexusCodec(Map codecsByEndpoint) { + this.codecsByEndpoint = Map.copyOf(codecsByEndpoint); } @Override @Nonnull public PayloadCodec withContext(@Nonnull SerializationContext context) { if (!(context instanceof NexusSerializationContext)) { - return new NexusCodec(); - } - NexusSerializationContext nexusSerializationContext = (NexusSerializationContext) context; - if (!EchoService.SERVICE_NAME.equals(nexusSerializationContext.getService()) - || !EchoService.ECHO_OPERATION_NAME.equals(nexusSerializationContext.getOperation())) { - throw new PayloadCodecException( - "Unexpected Nexus service or operation: " + nexusSerializationContext); + return this; } - if (SampleConfig.HMAC_ENDPOINT.equals(nexusSerializationContext.getEndpoint())) { - return new NexusCodec(NexusEncoding.HMAC); + String endpoint = ((NexusSerializationContext) context).getEndpoint(); + PayloadCodec codec = codecsByEndpoint.get(endpoint); + if (codec == null) { + throw new PayloadCodecException("Unknown Nexus endpoint: " + endpoint); } - if (SampleConfig.ZLIB_ENDPOINT.equals(nexusSerializationContext.getEndpoint())) { - return new NexusCodec(NexusEncoding.ZLIB); - } - throw new PayloadCodecException( - "Unknown Nexus endpoint: " + nexusSerializationContext.getEndpoint()); + return codec.withContext(context); } @Override @Nonnull public List encode(@Nonnull List payloads) { - return switch (selectedEncoding) { - case HMAC -> encodeHmac(payloads); - case ZLIB -> encodeZlib(payloads); - case NONE -> payloads; - }; + return payloads; } @Override @Nonnull public List decode(@Nonnull List payloads) { - return switch (selectedEncoding) { - case HMAC -> decodeHmac(payloads); - case ZLIB -> decodeZlib(payloads); - case NONE -> payloads; - }; - } - - private static List encodeHmac(List payloads) { - List encoded = new ArrayList<>(payloads.size()); - for (Payload payload : payloads) { - byte[] bytes = payload.toByteArray(); - byte[] signature = hmac(bytes); - byte[] signed = - ByteBuffer.allocate(signature.length + bytes.length).put(signature).put(bytes).array(); - encoded.add( - Payload.newBuilder() - .putMetadata( - EncodingKeys.METADATA_ENCODING_KEY, - ByteString.copyFromUtf8(NexusEncoding.HMAC.encodingName())) - .setData(ByteString.copyFrom(signed)) - .build()); - } - return encoded; - } - - private static List decodeHmac(List payloads) { - List decoded = new ArrayList<>(payloads.size()); - for (Payload payload : payloads) { - String encoding = - payload - .getMetadataOrDefault(EncodingKeys.METADATA_ENCODING_KEY, ByteString.EMPTY) - .toStringUtf8(); - if (!NexusEncoding.HMAC.encodingName().equals(encoding)) { - throw new PayloadCodecException("Expected a Nexus HMAC payload"); - } - byte[] signed = payload.getData().toByteArray(); - if (signed.length < HMAC_LENGTH) { - throw new PayloadCodecException("Nexus HMAC payload is too short"); - } - byte[] signature = Arrays.copyOfRange(signed, 0, HMAC_LENGTH); - byte[] bytes = Arrays.copyOfRange(signed, HMAC_LENGTH, signed.length); - if (!MessageDigest.isEqual(signature, hmac(bytes))) { - throw new PayloadCodecException("Nexus HMAC does not match"); - } - try { - decoded.add(Payload.parseFrom(bytes)); - } catch (InvalidProtocolBufferException e) { - throw new PayloadCodecException(e); - } - } - return decoded; - } - - private static List encodeZlib(List payloads) { - List encoded = new ArrayList<>(payloads.size()); - for (Payload payload : payloads) { - encoded.add( - Payload.newBuilder() - .putMetadata( - EncodingKeys.METADATA_ENCODING_KEY, - ByteString.copyFromUtf8(NexusEncoding.ZLIB.encodingName())) - .setData(ByteString.copyFrom(compress(payload.toByteArray()))) - .build()); - } - return encoded; - } - - private static List decodeZlib(List payloads) { - List decoded = new ArrayList<>(payloads.size()); - for (Payload payload : payloads) { - String encoding = - payload - .getMetadataOrDefault(EncodingKeys.METADATA_ENCODING_KEY, ByteString.EMPTY) - .toStringUtf8(); - if (!NexusEncoding.ZLIB.encodingName().equals(encoding)) { - throw new PayloadCodecException("Expected a Nexus zlib payload"); - } - try { - decoded.add(Payload.parseFrom(decompress(payload.getData().toByteArray()))); - } catch (InvalidProtocolBufferException e) { - throw new PayloadCodecException(e); - } - } - return decoded; - } - - private static byte[] hmac(byte[] bytes) { - try { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(HMAC_KEY); - return mac.doFinal(bytes); - } catch (GeneralSecurityException e) { - throw new PayloadCodecException(e); - } - } - - private static byte[] compress(byte[] bytes) { - try { - ByteArrayOutputStream output = new ByteArrayOutputStream(); - try (DeflaterOutputStream deflater = new DeflaterOutputStream(output)) { - deflater.write(bytes); - } - return output.toByteArray(); - } catch (IOException e) { - throw new PayloadCodecException(e); - } - } - - private static byte[] decompress(byte[] bytes) { - try (InflaterInputStream inflater = new InflaterInputStream(new ByteArrayInputStream(bytes))) { - return inflater.readAllBytes(); - } catch (IOException e) { - throw new PayloadCodecException(e); - } + return payloads; } } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusEncoding.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusEncoding.java index 49c66b57..f641b31c 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusEncoding.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusEncoding.java @@ -1,8 +1,7 @@ package io.temporal.samples.nexusserializationcontext; enum NexusEncoding { - NONE(""), - HMAC("binary/nexus-hmac"), + AES_GCM("binary/nexus-aes-gcm"), ZLIB("binary/nexus-zlib"); private final String encodingName; diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md b/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md index 39663283..8ba10e01 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md @@ -1,17 +1,25 @@ # Nexus serialization context -This sample calls the same synchronous Nexus operation through two endpoints. A -`PayloadCodec` uses `NexusSerializationContext` to choose HMAC for one endpoint and -zlib compression for the other. The caller schedules both operations before waiting -for their results, so each result must be decoded using the context of its own -endpoint. Non-Nexus payloads pass through the codec unchanged. +This sample calls the same synchronous Nexus operation through two endpoints that +share a handler task queue. `NexusCodec` uses `NexusSerializationContext` to select +the `PayloadCodec` registered for each endpoint: + +- One endpoint compresses with zlib, then encrypts with AES-GCM using Key A. +- The other endpoint encrypts with AES-GCM using Key B. + +The caller schedules both operations before waiting for their results, so each +result must be decoded using the context of its own endpoint. Non-Nexus payloads +pass through the codec unchanged. + +The encrypted payload metadata includes `binary/nexus-aes-gcm` and a sample key ID +(`key-a` or `key-b`), so you can see which key the endpoint selected. `NexusSerializationContext` works end to end for synchronous Nexus operations. The final result of an asynchronous operation does not receive `NexusSerializationContext`. -HMAC authenticates payloads but does not encrypt them. The hard-coded key is only -for this local example. For production encryption, use a secure key store and a -codec such as the [AWS Encryption SDK sample](../keymanagementencryption/awsencryptionsdk/README.md). +The hard-coded keys are only for this local example. For production encryption, +use a secure key store, as in the +[AWS Encryption SDK sample](../keymanagementencryption/awsencryptionsdk/README.md). Requires Java SDK 1.40.0 or later and a Temporal server with Nexus enabled. @@ -29,13 +37,13 @@ In another terminal, create the namespaces and endpoints: temporal operator namespace create --namespace nexus-serialization-handler temporal operator namespace create --namespace nexus-serialization-caller temporal operator nexus endpoint create \ - --name nexus-serialization-hmac \ + --name nexus-serialization-compressed-encrypted \ --target-namespace nexus-serialization-handler \ - --target-task-queue nexus-serialization-hmac-handler + --target-task-queue nexus-serialization-handler temporal operator nexus endpoint create \ - --name nexus-serialization-zlib \ + --name nexus-serialization-encrypted \ --target-namespace nexus-serialization-handler \ - --target-task-queue nexus-serialization-zlib-handler + --target-task-queue nexus-serialization-handler ``` Run each of the following in its own terminal from the repository root: @@ -58,6 +66,6 @@ Run each of the following in its own terminal from the repository root: The starter prints: ```text -HMAC endpoint result: Hello from Nexus -zlib endpoint result: Hello from Nexus +Compressed and encrypted endpoint result: Hello from Nexus +Encrypted endpoint result: Hello from Nexus ``` diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java index e6267d96..10e8ace5 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java @@ -3,19 +3,40 @@ import io.temporal.common.converter.CodecDataConverter; import io.temporal.common.converter.DataConverter; import io.temporal.common.converter.DefaultDataConverter; -import java.util.Collections; +import io.temporal.payload.codec.ChainCodec; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; public final class SampleConfig { - public static final String HMAC_ENDPOINT = "nexus-serialization-hmac"; - public static final String ZLIB_ENDPOINT = "nexus-serialization-zlib"; - public static final String HMAC_HANDLER_TASK_QUEUE = "nexus-serialization-hmac-handler"; - public static final String ZLIB_HANDLER_TASK_QUEUE = "nexus-serialization-zlib-handler"; + public static final String COMPRESSED_ENCRYPTED_ENDPOINT = + "nexus-serialization-compressed-encrypted"; + public static final String ENCRYPTED_ENDPOINT = "nexus-serialization-encrypted"; + public static final String HANDLER_TASK_QUEUE = "nexus-serialization-handler"; public static final String CALLER_TASK_QUEUE = "nexus-serialization-caller"; + static final String KEY_A_ID = "key-a"; + static final String KEY_B_ID = "key-b"; + + // Hard-coded keys are only for this local sample. + private static final SecretKey KEY_A = + new SecretKeySpec("sample-key-A-123".getBytes(StandardCharsets.UTF_8), "AES"); + private static final SecretKey KEY_B = + new SecretKeySpec("sample-key-B-123".getBytes(StandardCharsets.UTF_8), "AES"); private SampleConfig() {} public static DataConverter dataConverter() { return new CodecDataConverter( - DefaultDataConverter.newDefaultInstance(), Collections.singletonList(new NexusCodec())); + DefaultDataConverter.newDefaultInstance(), + List.of( + new NexusCodec( + Map.of( + COMPRESSED_ENCRYPTED_ENDPOINT, + // ChainCodec encodes last to first: compress, then encrypt. + new ChainCodec(List.of(new AesGcmCodec(KEY_A_ID, KEY_A), new ZlibCodec())), + ENCRYPTED_ENDPOINT, + new AesGcmCodec(KEY_B_ID, KEY_B))))); } } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/ZlibCodec.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/ZlibCodec.java new file mode 100644 index 00000000..e066e618 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/ZlibCodec.java @@ -0,0 +1,76 @@ +package io.temporal.samples.nexusserializationcontext; + +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; +import io.temporal.api.common.v1.Payload; +import io.temporal.common.converter.EncodingKeys; +import io.temporal.payload.codec.PayloadCodec; +import io.temporal.payload.codec.PayloadCodecException; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.InflaterInputStream; +import javax.annotation.Nonnull; + +/** Compresses Nexus payloads with zlib. */ +final class ZlibCodec implements PayloadCodec { + @Override + @Nonnull + public List encode(@Nonnull List payloads) { + List encoded = new ArrayList<>(payloads.size()); + for (Payload payload : payloads) { + encoded.add( + Payload.newBuilder() + .putMetadata( + EncodingKeys.METADATA_ENCODING_KEY, + ByteString.copyFromUtf8(NexusEncoding.ZLIB.encodingName())) + .setData(ByteString.copyFrom(compress(payload.toByteArray()))) + .build()); + } + return encoded; + } + + @Override + @Nonnull + public List decode(@Nonnull List payloads) { + List decoded = new ArrayList<>(payloads.size()); + for (Payload payload : payloads) { + String encoding = + payload + .getMetadataOrDefault(EncodingKeys.METADATA_ENCODING_KEY, ByteString.EMPTY) + .toStringUtf8(); + if (!NexusEncoding.ZLIB.encodingName().equals(encoding)) { + throw new PayloadCodecException("Expected a Nexus zlib payload"); + } + try { + decoded.add(Payload.parseFrom(decompress(payload.getData().toByteArray()))); + } catch (InvalidProtocolBufferException e) { + throw new PayloadCodecException(e); + } + } + return decoded; + } + + private static byte[] compress(byte[] bytes) { + try { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (DeflaterOutputStream deflater = new DeflaterOutputStream(output)) { + deflater.write(bytes); + } + return output.toByteArray(); + } catch (IOException e) { + throw new PayloadCodecException(e); + } + } + + private static byte[] decompress(byte[] bytes) { + try (InflaterInputStream inflater = new InflaterInputStream(new ByteArrayInputStream(bytes))) { + return inflater.readAllBytes(); + } catch (IOException e) { + throw new PayloadCodecException(e); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerStarter.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerStarter.java index a6e01949..bb1117a0 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerStarter.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerStarter.java @@ -17,8 +17,9 @@ public static void main(String[] args) { CallerWorkflow.class, WorkflowOptions.newBuilder().setTaskQueue(SampleConfig.CALLER_TASK_QUEUE).build()); - EndpointResults results = workflow.callBoth("Hello from Nexus"); - System.out.println("HMAC endpoint result: " + results.hmacResult()); - System.out.println("zlib endpoint result: " + results.zlibResult()); + EndpointResults results = workflow.echoThroughBothEndpoints("Hello from Nexus"); + System.out.println( + "Compressed and encrypted endpoint result: " + results.compressedEncryptedResult()); + System.out.println("Encrypted endpoint result: " + results.encryptedResult()); } } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflow.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflow.java index 5f5ffc4a..b0b27c71 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflow.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflow.java @@ -6,5 +6,5 @@ @WorkflowInterface public interface CallerWorkflow { @WorkflowMethod - EndpointResults callBoth(String message); + EndpointResults echoThroughBothEndpoints(String message); } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflowImpl.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflowImpl.java index 55f97cf7..e8d851be 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflowImpl.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflowImpl.java @@ -10,16 +10,17 @@ public class CallerWorkflowImpl implements CallerWorkflow { @Override - public EndpointResults callBoth(String message) { - EchoService hmacService = serviceFor(SampleConfig.HMAC_ENDPOINT); - EchoService zlibService = serviceFor(SampleConfig.ZLIB_ENDPOINT); + public EndpointResults echoThroughBothEndpoints(String message) { + EchoService compressedEncryptedService = serviceFor(SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT); + EchoService encryptedService = serviceFor(SampleConfig.ENCRYPTED_ENDPOINT); // Start both before awaiting either result. Each result must keep its own endpoint context. - NexusOperationHandle hmacOperation = - Workflow.startNexusOperation(hmacService::echo, message); - NexusOperationHandle zlibOperation = - Workflow.startNexusOperation(zlibService::echo, message); - return new EndpointResults(hmacOperation.getResult().get(), zlibOperation.getResult().get()); + NexusOperationHandle compressedEncryptedOperation = + Workflow.startNexusOperation(compressedEncryptedService::echo, message); + NexusOperationHandle encryptedOperation = + Workflow.startNexusOperation(encryptedService::echo, message); + return new EndpointResults( + compressedEncryptedOperation.getResult().get(), encryptedOperation.getResult().get()); } private static EchoService serviceFor(String endpoint) { diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/EndpointResults.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/EndpointResults.java index 8bbb61d9..f283c716 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/EndpointResults.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/EndpointResults.java @@ -1,3 +1,3 @@ package io.temporal.samples.nexusserializationcontext.caller; -public record EndpointResults(String hmacResult, String zlibResult) {} +public record EndpointResults(String compressedEncryptedResult, String encryptedResult) {} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/HandlerWorker.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/HandlerWorker.java index 82f051e8..79e1325d 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/HandlerWorker.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/HandlerWorker.java @@ -15,11 +15,8 @@ public static void main(String[] args) { WorkflowClientOptions.newBuilder().setDataConverter(SampleConfig.dataConverter())); WorkerFactory factory = WorkerFactory.newInstance(client); - Worker hmacWorker = factory.newWorker(SampleConfig.HMAC_HANDLER_TASK_QUEUE); - hmacWorker.registerNexusServiceImplementation(new EchoServiceImpl()); - - Worker zlibWorker = factory.newWorker(SampleConfig.ZLIB_HANDLER_TASK_QUEUE); - zlibWorker.registerNexusServiceImplementation(new EchoServiceImpl()); + Worker worker = factory.newWorker(SampleConfig.HANDLER_TASK_QUEUE); + worker.registerNexusServiceImplementation(new EchoServiceImpl()); factory.start(); } diff --git a/core/src/test/java/io/temporal/samples/nexusserializationcontext/NexusCodecTest.java b/core/src/test/java/io/temporal/samples/nexusserializationcontext/NexusCodecTest.java new file mode 100644 index 00000000..d1458ee5 --- /dev/null +++ b/core/src/test/java/io/temporal/samples/nexusserializationcontext/NexusCodecTest.java @@ -0,0 +1,94 @@ +package io.temporal.samples.nexusserializationcontext; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.protobuf.ByteString; +import io.temporal.api.common.v1.Payload; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.EncodingKeys; +import io.temporal.payload.codec.PayloadCodecException; +import io.temporal.payload.context.NexusSerializationContext; +import io.temporal.payload.context.SerializationContext; +import io.temporal.samples.nexusserializationcontext.service.EchoService; +import org.junit.jupiter.api.Test; + +class NexusCodecTest { + @Test + void encryptsWithTheKeyForEachEndpoint() { + DataConverter keyAConverter = converterFor(SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT); + DataConverter keyBConverter = converterFor(SampleConfig.ENCRYPTED_ENDPOINT); + + Payload keyAPayload = keyAConverter.toPayload("hello").orElseThrow(); + Payload keyBPayload = keyBConverter.toPayload("hello").orElseThrow(); + + assertEquals( + NexusEncoding.AES_GCM.encodingName(), + keyAPayload.getMetadataOrThrow(EncodingKeys.METADATA_ENCODING_KEY).toStringUtf8()); + assertEquals( + NexusEncoding.AES_GCM.encodingName(), + keyBPayload.getMetadataOrThrow(EncodingKeys.METADATA_ENCODING_KEY).toStringUtf8()); + assertEquals( + SampleConfig.KEY_A_ID, + keyAPayload.getMetadataOrThrow(AesGcmCodec.KEY_ID_METADATA_KEY).toStringUtf8()); + assertEquals( + SampleConfig.KEY_B_ID, + keyBPayload.getMetadataOrThrow(AesGcmCodec.KEY_ID_METADATA_KEY).toStringUtf8()); + assertEquals("hello", keyAConverter.fromPayload(keyAPayload, String.class, String.class)); + assertEquals("hello", keyBConverter.fromPayload(keyBPayload, String.class, String.class)); + assertThrows( + PayloadCodecException.class, + () -> keyAConverter.fromPayload(keyBPayload, String.class, String.class)); + Payload keyAPayloadMarkedAsB = + keyAPayload.toBuilder() + .putMetadata( + AesGcmCodec.KEY_ID_METADATA_KEY, ByteString.copyFromUtf8(SampleConfig.KEY_B_ID)) + .build(); + assertThrows( + PayloadCodecException.class, + () -> keyBConverter.fromPayload(keyAPayloadMarkedAsB, String.class, String.class)); + } + + @Test + void compressesBeforeEncryptingForTheFirstEndpoint() { + String message = "repeat me ".repeat(100); + Payload compressed = + converterFor(SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT).toPayload(message).orElseThrow(); + Payload encryptedOnly = + converterFor(SampleConfig.ENCRYPTED_ENDPOINT).toPayload(message).orElseThrow(); + + assertTrue(compressed.getData().size() < encryptedOnly.getData().size()); + } + + @Test + void leavesNonNexusPayloadsUnchanged() { + DataConverter converter = + SampleConfig.dataConverter().withContext(new SerializationContext() {}); + + Payload payload = converter.toPayload("hello").orElseThrow(); + + assertEquals( + "json/plain", + payload.getMetadataOrThrow(EncodingKeys.METADATA_ENCODING_KEY).toStringUtf8()); + assertEquals("hello", converter.fromPayload(payload, String.class, String.class)); + } + + @Test + void rejectsUnknownEndpoints() { + DataConverter converter = + SampleConfig.dataConverter() + .withContext( + new NexusSerializationContext( + "unknown", EchoService.SERVICE_NAME, EchoService.ECHO_OPERATION_NAME)); + + assertThrows(PayloadCodecException.class, () -> converter.toPayload("hello")); + } + + private static DataConverter converterFor(String endpoint) { + return SampleConfig.dataConverter() + .withContext( + new NexusSerializationContext( + endpoint, EchoService.SERVICE_NAME, EchoService.ECHO_OPERATION_NAME)); + } +} From c262de73e66070c1573b69652e7880e5b62d008e Mon Sep 17 00:00:00 2001 From: Joshua Frenchwood Date: Tue, 29 Sep 2026 15:58:35 -0500 Subject: [PATCH 3/8] Moving DataConverter logic --- .../AesGcmCodec.java | 4 +- .../nexusserializationcontext/README.md | 34 +++++++++------ .../SampleConfig.java | 35 ++------------- .../nexusserializationcontext/ZlibCodec.java | 2 +- .../caller/CallerStarter.java | 5 +-- .../caller/CallerWorker.java | 40 +++++++++++++++-- .../CompressedEncryptedHandlerWorker.java | 43 +++++++++++++++++++ .../handler/EncryptedHandlerWorker.java | 38 ++++++++++++++++ .../handler/HandlerWorker.java | 23 ---------- .../NexusCodecTest.java | 42 +++++++++++++++--- 10 files changed, 185 insertions(+), 81 deletions(-) create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/CompressedEncryptedHandlerWorker.java create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EncryptedHandlerWorker.java delete mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/HandlerWorker.java diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/AesGcmCodec.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/AesGcmCodec.java index cdd4a140..464edf4d 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/AesGcmCodec.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/AesGcmCodec.java @@ -17,7 +17,7 @@ import javax.crypto.spec.GCMParameterSpec; /** Encrypts Nexus payloads with the key assigned to an endpoint. */ -final class AesGcmCodec implements PayloadCodec { +public final class AesGcmCodec implements PayloadCodec { static final String KEY_ID_METADATA_KEY = "encryption-key-id"; private static final String CIPHER = "AES/GCM/NoPadding"; @@ -28,7 +28,7 @@ final class AesGcmCodec implements PayloadCodec { private final String keyId; private final SecretKey key; - AesGcmCodec(String keyId, SecretKey key) { + public AesGcmCodec(String keyId, SecretKey key) { this.keyId = keyId; this.key = key; } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md b/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md index 8ba10e01..293e2c80 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md @@ -1,15 +1,19 @@ # Nexus serialization context -This sample calls the same synchronous Nexus operation through two endpoints that -share a handler task queue. `NexusCodec` uses `NexusSerializationContext` to select -the `PayloadCodec` registered for each endpoint: +This sample calls the same synchronous Nexus operation through two endpoints. +Each endpoint routes to its own handler namespace and worker. The caller's +`NexusCodec` uses `NexusSerializationContext` to select the `PayloadCodec` +registered for each endpoint: - One endpoint compresses with zlib, then encrypts with AES-GCM using Key A. - The other endpoint encrypts with AES-GCM using Key B. +The caller configures both codecs. Each handler worker uses its own codec and key +without selecting by endpoint. + The caller schedules both operations before waiting for their results, so each -result must be decoded using the context of its own endpoint. Non-Nexus payloads -pass through the codec unchanged. +result must be decoded using the context of its own endpoint. The caller's +`NexusCodec` leaves non-Nexus payloads unchanged. The encrypted payload metadata includes `binary/nexus-aes-gcm` and a sample key ID (`key-a` or `key-b`), so you can see which key the endpoint selected. @@ -34,23 +38,29 @@ temporal server start-dev In another terminal, create the namespaces and endpoints: ```bash -temporal operator namespace create --namespace nexus-serialization-handler temporal operator namespace create --namespace nexus-serialization-caller +temporal operator namespace create --namespace nexus-serialization-key-a-handler +temporal operator namespace create --namespace nexus-serialization-key-b-handler temporal operator nexus endpoint create \ --name nexus-serialization-compressed-encrypted \ - --target-namespace nexus-serialization-handler \ - --target-task-queue nexus-serialization-handler + --target-namespace nexus-serialization-key-a-handler \ + --target-task-queue nexus-serialization-key-a-handler temporal operator nexus endpoint create \ --name nexus-serialization-encrypted \ - --target-namespace nexus-serialization-handler \ - --target-task-queue nexus-serialization-handler + --target-namespace nexus-serialization-key-b-handler \ + --target-task-queue nexus-serialization-key-b-handler ``` Run each of the following in its own terminal from the repository root: ```bash -./gradlew -q :core:execute -PmainClass=io.temporal.samples.nexusserializationcontext.handler.HandlerWorker \ - --args="-namespace nexus-serialization-handler" +./gradlew -q :core:execute -PmainClass=io.temporal.samples.nexusserializationcontext.handler.CompressedEncryptedHandlerWorker \ + --args="-namespace nexus-serialization-key-a-handler" +``` + +```bash +./gradlew -q :core:execute -PmainClass=io.temporal.samples.nexusserializationcontext.handler.EncryptedHandlerWorker \ + --args="-namespace nexus-serialization-key-b-handler" ``` ```bash diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java index 10e8ace5..2c3cd61a 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java @@ -1,42 +1,15 @@ package io.temporal.samples.nexusserializationcontext; -import io.temporal.common.converter.CodecDataConverter; -import io.temporal.common.converter.DataConverter; -import io.temporal.common.converter.DefaultDataConverter; -import io.temporal.payload.codec.ChainCodec; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Map; -import javax.crypto.SecretKey; -import javax.crypto.spec.SecretKeySpec; - public final class SampleConfig { public static final String COMPRESSED_ENCRYPTED_ENDPOINT = "nexus-serialization-compressed-encrypted"; public static final String ENCRYPTED_ENDPOINT = "nexus-serialization-encrypted"; - public static final String HANDLER_TASK_QUEUE = "nexus-serialization-handler"; - public static final String CALLER_TASK_QUEUE = "nexus-serialization-caller"; - static final String KEY_A_ID = "key-a"; - static final String KEY_B_ID = "key-b"; + public static final String KEY_A_ID = "key-a"; + public static final String KEY_B_ID = "key-b"; // Hard-coded keys are only for this local sample. - private static final SecretKey KEY_A = - new SecretKeySpec("sample-key-A-123".getBytes(StandardCharsets.UTF_8), "AES"); - private static final SecretKey KEY_B = - new SecretKeySpec("sample-key-B-123".getBytes(StandardCharsets.UTF_8), "AES"); + public static final String KEY_A_VALUE = "sample-key-A-123"; + public static final String KEY_B_VALUE = "sample-key-B-123"; private SampleConfig() {} - - public static DataConverter dataConverter() { - return new CodecDataConverter( - DefaultDataConverter.newDefaultInstance(), - List.of( - new NexusCodec( - Map.of( - COMPRESSED_ENCRYPTED_ENDPOINT, - // ChainCodec encodes last to first: compress, then encrypt. - new ChainCodec(List.of(new AesGcmCodec(KEY_A_ID, KEY_A), new ZlibCodec())), - ENCRYPTED_ENDPOINT, - new AesGcmCodec(KEY_B_ID, KEY_B))))); - } } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/ZlibCodec.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/ZlibCodec.java index e066e618..1a61da01 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/ZlibCodec.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/ZlibCodec.java @@ -16,7 +16,7 @@ import javax.annotation.Nonnull; /** Compresses Nexus payloads with zlib. */ -final class ZlibCodec implements PayloadCodec { +public final class ZlibCodec implements PayloadCodec { @Override @Nonnull public List encode(@Nonnull List payloads) { diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerStarter.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerStarter.java index bb1117a0..fc09b03b 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerStarter.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerStarter.java @@ -4,18 +4,17 @@ import io.temporal.client.WorkflowClientOptions; import io.temporal.client.WorkflowOptions; import io.temporal.samples.nexus.options.ClientOptions; -import io.temporal.samples.nexusserializationcontext.SampleConfig; public class CallerStarter { public static void main(String[] args) { WorkflowClient client = ClientOptions.getWorkflowClient( args, - WorkflowClientOptions.newBuilder().setDataConverter(SampleConfig.dataConverter())); + WorkflowClientOptions.newBuilder().setDataConverter(CallerWorker.dataConverter())); CallerWorkflow workflow = client.newWorkflowStub( CallerWorkflow.class, - WorkflowOptions.newBuilder().setTaskQueue(SampleConfig.CALLER_TASK_QUEUE).build()); + WorkflowOptions.newBuilder().setTaskQueue(CallerWorker.TASK_QUEUE).build()); EndpointResults results = workflow.echoThroughBothEndpoints("Hello from Nexus"); System.out.println( diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorker.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorker.java index a7d8e93f..e9a1ff90 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorker.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorker.java @@ -2,20 +2,54 @@ import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.converter.CodecDataConverter; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.payload.codec.ChainCodec; +import io.temporal.payload.codec.PayloadCodec; import io.temporal.samples.nexus.options.ClientOptions; +import io.temporal.samples.nexusserializationcontext.AesGcmCodec; +import io.temporal.samples.nexusserializationcontext.NexusCodec; import io.temporal.samples.nexusserializationcontext.SampleConfig; +import io.temporal.samples.nexusserializationcontext.ZlibCodec; import io.temporal.worker.Worker; import io.temporal.worker.WorkerFactory; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; public class CallerWorker { + static final String TASK_QUEUE = "nexus-serialization-caller"; + public static void main(String[] args) { WorkflowClient client = ClientOptions.getWorkflowClient( - args, - WorkflowClientOptions.newBuilder().setDataConverter(SampleConfig.dataConverter())); + args, WorkflowClientOptions.newBuilder().setDataConverter(dataConverter())); WorkerFactory factory = WorkerFactory.newInstance(client); - Worker worker = factory.newWorker(SampleConfig.CALLER_TASK_QUEUE); + Worker worker = factory.newWorker(TASK_QUEUE); worker.registerWorkflowImplementationTypes(CallerWorkflowImpl.class); factory.start(); } + + public static DataConverter dataConverter() { + SecretKey keyA = + new SecretKeySpec(SampleConfig.KEY_A_VALUE.getBytes(StandardCharsets.UTF_8), "AES"); + SecretKey keyB = + new SecretKeySpec(SampleConfig.KEY_B_VALUE.getBytes(StandardCharsets.UTF_8), "AES"); + // ChainCodec encodes last to first: compress, then encrypt. + PayloadCodec compressedEncrypted = + new ChainCodec(List.of(new AesGcmCodec(SampleConfig.KEY_A_ID, keyA), new ZlibCodec())); + PayloadCodec encrypted = new AesGcmCodec(SampleConfig.KEY_B_ID, keyB); + return new CodecDataConverter( + DefaultDataConverter.newDefaultInstance(), + List.of( + new NexusCodec( + Map.of( + SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT, + compressedEncrypted, + SampleConfig.ENCRYPTED_ENDPOINT, + encrypted)))); + } } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/CompressedEncryptedHandlerWorker.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/CompressedEncryptedHandlerWorker.java new file mode 100644 index 00000000..708593d0 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/CompressedEncryptedHandlerWorker.java @@ -0,0 +1,43 @@ +package io.temporal.samples.nexusserializationcontext.handler; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.converter.CodecDataConverter; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.payload.codec.ChainCodec; +import io.temporal.samples.nexus.options.ClientOptions; +import io.temporal.samples.nexusserializationcontext.AesGcmCodec; +import io.temporal.samples.nexusserializationcontext.SampleConfig; +import io.temporal.samples.nexusserializationcontext.ZlibCodec; +import io.temporal.worker.Worker; +import io.temporal.worker.WorkerFactory; +import java.nio.charset.StandardCharsets; +import java.util.List; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +public class CompressedEncryptedHandlerWorker { + private static final String TASK_QUEUE = "nexus-serialization-key-a-handler"; + + public static void main(String[] args) { + WorkflowClient client = + ClientOptions.getWorkflowClient( + args, WorkflowClientOptions.newBuilder().setDataConverter(dataConverter())); + WorkerFactory factory = WorkerFactory.newInstance(client); + Worker worker = factory.newWorker(TASK_QUEUE); + worker.registerNexusServiceImplementation(new EchoServiceImpl()); + factory.start(); + } + + public static DataConverter dataConverter() { + SecretKey keyA = + new SecretKeySpec(SampleConfig.KEY_A_VALUE.getBytes(StandardCharsets.UTF_8), "AES"); + // ChainCodec encodes last to first: compress, then encrypt. + return new CodecDataConverter( + DefaultDataConverter.newDefaultInstance(), + List.of( + new ChainCodec( + List.of(new AesGcmCodec(SampleConfig.KEY_A_ID, keyA), new ZlibCodec())))); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EncryptedHandlerWorker.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EncryptedHandlerWorker.java new file mode 100644 index 00000000..85fdbf03 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EncryptedHandlerWorker.java @@ -0,0 +1,38 @@ +package io.temporal.samples.nexusserializationcontext.handler; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.converter.CodecDataConverter; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.samples.nexus.options.ClientOptions; +import io.temporal.samples.nexusserializationcontext.AesGcmCodec; +import io.temporal.samples.nexusserializationcontext.SampleConfig; +import io.temporal.worker.Worker; +import io.temporal.worker.WorkerFactory; +import java.nio.charset.StandardCharsets; +import java.util.List; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +public class EncryptedHandlerWorker { + private static final String TASK_QUEUE = "nexus-serialization-key-b-handler"; + + public static void main(String[] args) { + WorkflowClient client = + ClientOptions.getWorkflowClient( + args, WorkflowClientOptions.newBuilder().setDataConverter(dataConverter())); + WorkerFactory factory = WorkerFactory.newInstance(client); + Worker worker = factory.newWorker(TASK_QUEUE); + worker.registerNexusServiceImplementation(new EchoServiceImpl()); + factory.start(); + } + + public static DataConverter dataConverter() { + SecretKey keyB = + new SecretKeySpec(SampleConfig.KEY_B_VALUE.getBytes(StandardCharsets.UTF_8), "AES"); + return new CodecDataConverter( + DefaultDataConverter.newDefaultInstance(), + List.of(new AesGcmCodec(SampleConfig.KEY_B_ID, keyB))); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/HandlerWorker.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/HandlerWorker.java deleted file mode 100644 index 79e1325d..00000000 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/HandlerWorker.java +++ /dev/null @@ -1,23 +0,0 @@ -package io.temporal.samples.nexusserializationcontext.handler; - -import io.temporal.client.WorkflowClient; -import io.temporal.client.WorkflowClientOptions; -import io.temporal.samples.nexus.options.ClientOptions; -import io.temporal.samples.nexusserializationcontext.SampleConfig; -import io.temporal.worker.Worker; -import io.temporal.worker.WorkerFactory; - -public class HandlerWorker { - public static void main(String[] args) { - WorkflowClient client = - ClientOptions.getWorkflowClient( - args, - WorkflowClientOptions.newBuilder().setDataConverter(SampleConfig.dataConverter())); - WorkerFactory factory = WorkerFactory.newInstance(client); - - Worker worker = factory.newWorker(SampleConfig.HANDLER_TASK_QUEUE); - worker.registerNexusServiceImplementation(new EchoServiceImpl()); - - factory.start(); - } -} diff --git a/core/src/test/java/io/temporal/samples/nexusserializationcontext/NexusCodecTest.java b/core/src/test/java/io/temporal/samples/nexusserializationcontext/NexusCodecTest.java index d1458ee5..88bc8465 100644 --- a/core/src/test/java/io/temporal/samples/nexusserializationcontext/NexusCodecTest.java +++ b/core/src/test/java/io/temporal/samples/nexusserializationcontext/NexusCodecTest.java @@ -11,6 +11,9 @@ import io.temporal.payload.codec.PayloadCodecException; import io.temporal.payload.context.NexusSerializationContext; import io.temporal.payload.context.SerializationContext; +import io.temporal.samples.nexusserializationcontext.caller.CallerWorker; +import io.temporal.samples.nexusserializationcontext.handler.CompressedEncryptedHandlerWorker; +import io.temporal.samples.nexusserializationcontext.handler.EncryptedHandlerWorker; import io.temporal.samples.nexusserializationcontext.service.EchoService; import org.junit.jupiter.api.Test; @@ -64,7 +67,7 @@ void compressesBeforeEncryptingForTheFirstEndpoint() { @Test void leavesNonNexusPayloadsUnchanged() { DataConverter converter = - SampleConfig.dataConverter().withContext(new SerializationContext() {}); + CallerWorker.dataConverter().withContext(new SerializationContext() {}); Payload payload = converter.toPayload("hello").orElseThrow(); @@ -77,7 +80,7 @@ void leavesNonNexusPayloadsUnchanged() { @Test void rejectsUnknownEndpoints() { DataConverter converter = - SampleConfig.dataConverter() + CallerWorker.dataConverter() .withContext( new NexusSerializationContext( "unknown", EchoService.SERVICE_NAME, EchoService.ECHO_OPERATION_NAME)); @@ -85,10 +88,37 @@ void rejectsUnknownEndpoints() { assertThrows(PayloadCodecException.class, () -> converter.toPayload("hello")); } + @Test + void eachHandlerUsesItsOwnCodecWithoutEndpointContext() { + DataConverter callerA = converterFor(SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT); + DataConverter callerB = converterFor(SampleConfig.ENCRYPTED_ENDPOINT); + DataConverter handlerA = CompressedEncryptedHandlerWorker.dataConverter(); + DataConverter handlerB = EncryptedHandlerWorker.dataConverter(); + + Payload requestA = callerA.toPayload("hello").orElseThrow(); + Payload requestB = callerB.toPayload("hello").orElseThrow(); + assertEquals("hello", handlerA.fromPayload(requestA, String.class, String.class)); + assertEquals("hello", handlerB.fromPayload(requestB, String.class, String.class)); + + Payload resultA = handlerA.toPayload("reply").orElseThrow(); + Payload resultB = handlerB.toPayload("reply").orElseThrow(); + assertEquals("reply", callerA.fromPayload(resultA, String.class, String.class)); + assertEquals("reply", callerB.fromPayload(resultB, String.class, String.class)); + + assertThrows( + PayloadCodecException.class, + () -> handlerA.fromPayload(requestB, String.class, String.class)); + assertThrows( + PayloadCodecException.class, + () -> handlerB.fromPayload(requestA, String.class, String.class)); + } + private static DataConverter converterFor(String endpoint) { - return SampleConfig.dataConverter() - .withContext( - new NexusSerializationContext( - endpoint, EchoService.SERVICE_NAME, EchoService.ECHO_OPERATION_NAME)); + return CallerWorker.dataConverter().withContext(contextFor(endpoint)); + } + + private static NexusSerializationContext contextFor(String endpoint) { + return new NexusSerializationContext( + endpoint, EchoService.SERVICE_NAME, EchoService.ECHO_OPERATION_NAME); } } From bc717ace52bbc5be60680b1e102f226ebcc797b5 Mon Sep 17 00:00:00 2001 From: Joshua Frenchwood Date: Tue, 29 Sep 2026 22:30:49 -0500 Subject: [PATCH 4/8] Fixing stalled unit test --- .../java/io/temporal/samples/hello/HelloAccumulator.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/io/temporal/samples/hello/HelloAccumulator.java b/core/src/main/java/io/temporal/samples/hello/HelloAccumulator.java index 2f8d6745..037b0f72 100644 --- a/core/src/main/java/io/temporal/samples/hello/HelloAccumulator.java +++ b/core/src/main/java/io/temporal/samples/hello/HelloAccumulator.java @@ -15,6 +15,7 @@ import io.temporal.serviceclient.WorkflowServiceStubs; import io.temporal.worker.Worker; import io.temporal.worker.WorkerFactory; +import io.temporal.workflow.Promise; import io.temporal.workflow.SignalMethod; import io.temporal.workflow.Workflow; import io.temporal.workflow.WorkflowInterface; @@ -198,8 +199,10 @@ public String accumulateGreetings( // - if exit signal is received, process any remaining signals and exit do { - boolean timedout = - !Workflow.await(MAX_AWAIT_TIME, () -> !unprocessedGreetings.isEmpty() || exitRequested); + Promise timer = Workflow.newTimer(MAX_AWAIT_TIME); + Workflow.await( + () -> timer.isCompleted() || !unprocessedGreetings.isEmpty() || exitRequested); + boolean timedout = timer.isCompleted() && unprocessedGreetings.isEmpty() && !exitRequested; while (!unprocessedGreetings.isEmpty()) { processGreeting(unprocessedGreetings.removeFirst()); From a30fe100a56760c20197fdb1499d0b20d8208158 Mon Sep 17 00:00:00 2001 From: Joshua Frenchwood Date: Thu, 1 Oct 2026 13:08:15 -0500 Subject: [PATCH 5/8] Adding Endpoint name to Payload metadata --- .../AesGcmCodec.java | 30 +++++++++++++++++-- .../nexusserializationcontext/README.md | 16 ++++++++-- .../SampleConfig.java | 1 + .../NexusCodecTest.java | 23 ++++++++++++++ 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/AesGcmCodec.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/AesGcmCodec.java index 464edf4d..8fdcc530 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/AesGcmCodec.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/AesGcmCodec.java @@ -6,6 +6,8 @@ import io.temporal.common.converter.EncodingKeys; import io.temporal.payload.codec.PayloadCodec; import io.temporal.payload.codec.PayloadCodecException; +import io.temporal.payload.context.NexusSerializationContext; +import io.temporal.payload.context.SerializationContext; import java.nio.ByteBuffer; import java.security.GeneralSecurityException; import java.security.SecureRandom; @@ -15,6 +17,7 @@ import javax.crypto.Cipher; import javax.crypto.SecretKey; import javax.crypto.spec.GCMParameterSpec; +import org.apache.commons.lang.StringUtils; /** Encrypts Nexus payloads with the key assigned to an endpoint. */ public final class AesGcmCodec implements PayloadCodec { @@ -27,10 +30,27 @@ public final class AesGcmCodec implements PayloadCodec { private final String keyId; private final SecretKey key; + private final String endpoint; public AesGcmCodec(String keyId, SecretKey key) { this.keyId = keyId; this.key = key; + this.endpoint = null; + } + + private AesGcmCodec(String keyId, SecretKey key, String endpoint) { + this.keyId = keyId; + this.key = key; + this.endpoint = endpoint; + } + + @Override + @Nonnull + public PayloadCodec withContext(@Nonnull SerializationContext context) { + if (context instanceof NexusSerializationContext nexusSerializationContext) { + return new AesGcmCodec(keyId, key, nexusSerializationContext.getEndpoint()); + } + return this; } @Override @@ -38,14 +58,18 @@ public AesGcmCodec(String keyId, SecretKey key) { public List encode(@Nonnull List payloads) { List encoded = new ArrayList<>(payloads.size()); for (Payload payload : payloads) { - encoded.add( + Payload.Builder encrypted = Payload.newBuilder() .putMetadata( EncodingKeys.METADATA_ENCODING_KEY, ByteString.copyFromUtf8(NexusEncoding.AES_GCM.encodingName())) .putMetadata(KEY_ID_METADATA_KEY, ByteString.copyFromUtf8(keyId)) - .setData(ByteString.copyFrom(encrypt(payload.toByteArray()))) - .build()); + .setData(ByteString.copyFrom(encrypt(payload.toByteArray()))); + if (StringUtils.isNotBlank(endpoint)) { + encrypted.putMetadata( + SampleConfig.ENDPOINT_METADATA_KEY, ByteString.copyFromUtf8(endpoint)); + } + encoded.add(encrypted.build()); } return encoded; } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md b/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md index 293e2c80..f84700b7 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md @@ -15,8 +15,18 @@ The caller schedules both operations before waiting for their results, so each result must be decoded using the context of its own endpoint. The caller's `NexusCodec` leaves non-Nexus payloads unchanged. -The encrypted payload metadata includes `binary/nexus-aes-gcm` and a sample key ID -(`key-a` or `key-b`), so you can see which key the endpoint selected. +The outer encrypted payload stores the endpoint name in `nexus-endpoint` metadata, +alongside `binary/nexus-aes-gcm` and a sample key ID (`key-a` or `key-b`). A Codec +Server can use the endpoint name to select the matching key and decompression +chain without SDK context. + +For the compressed endpoint, the outer payload's decoded metadata looks like: + +```text +encoding: binary/nexus-aes-gcm +encryption-key-id: key-a +nexus-endpoint: nexus-serialization-compressed-encrypted +``` `NexusSerializationContext` works end to end for synchronous Nexus operations. The final result of an asynchronous operation does not receive `NexusSerializationContext`. @@ -73,7 +83,7 @@ Run each of the following in its own terminal from the repository root: --args="-namespace nexus-serialization-caller" ``` -The starter prints: +The starter will print: ```text Compressed and encrypted endpoint result: Hello from Nexus diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java index 2c3cd61a..b557124e 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java @@ -4,6 +4,7 @@ public final class SampleConfig { public static final String COMPRESSED_ENCRYPTED_ENDPOINT = "nexus-serialization-compressed-encrypted"; public static final String ENCRYPTED_ENDPOINT = "nexus-serialization-encrypted"; + public static final String ENDPOINT_METADATA_KEY = "nexus-endpoint-name"; public static final String KEY_A_ID = "key-a"; public static final String KEY_B_ID = "key-b"; diff --git a/core/src/test/java/io/temporal/samples/nexusserializationcontext/NexusCodecTest.java b/core/src/test/java/io/temporal/samples/nexusserializationcontext/NexusCodecTest.java index 88bc8465..7af69172 100644 --- a/core/src/test/java/io/temporal/samples/nexusserializationcontext/NexusCodecTest.java +++ b/core/src/test/java/io/temporal/samples/nexusserializationcontext/NexusCodecTest.java @@ -38,6 +38,12 @@ void encryptsWithTheKeyForEachEndpoint() { assertEquals( SampleConfig.KEY_B_ID, keyBPayload.getMetadataOrThrow(AesGcmCodec.KEY_ID_METADATA_KEY).toStringUtf8()); + assertEquals( + SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT, + keyAPayload.getMetadataOrThrow(SampleConfig.ENDPOINT_METADATA_KEY).toStringUtf8()); + assertEquals( + SampleConfig.ENCRYPTED_ENDPOINT, + keyBPayload.getMetadataOrThrow(SampleConfig.ENDPOINT_METADATA_KEY).toStringUtf8()); assertEquals("hello", keyAConverter.fromPayload(keyAPayload, String.class, String.class)); assertEquals("hello", keyBConverter.fromPayload(keyBPayload, String.class, String.class)); assertThrows( @@ -105,6 +111,23 @@ void eachHandlerUsesItsOwnCodecWithoutEndpointContext() { assertEquals("reply", callerA.fromPayload(resultA, String.class, String.class)); assertEquals("reply", callerB.fromPayload(resultB, String.class, String.class)); + Payload contextualResultA = + handlerA + .withContext(contextFor(SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT)) + .toPayload("reply") + .orElseThrow(); + Payload contextualResultB = + handlerB + .withContext(contextFor(SampleConfig.ENCRYPTED_ENDPOINT)) + .toPayload("reply") + .orElseThrow(); + assertEquals( + SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT, + contextualResultA.getMetadataOrThrow(SampleConfig.ENDPOINT_METADATA_KEY).toStringUtf8()); + assertEquals( + SampleConfig.ENCRYPTED_ENDPOINT, + contextualResultB.getMetadataOrThrow(SampleConfig.ENDPOINT_METADATA_KEY).toStringUtf8()); + assertThrows( PayloadCodecException.class, () -> handlerA.fromPayload(requestB, String.class, String.class)); From 548fb137e14410486c86805f5cd73479f6430de7 Mon Sep 17 00:00:00 2001 From: Joshua Frenchwood Date: Thu, 1 Oct 2026 15:43:13 -0500 Subject: [PATCH 6/8] Async operation encryption support --- .../nexusserializationcontext/README.md | 63 +++++++-- .../SampleConfig.java | 7 + .../caller/CallerStarter.java | 11 +- .../caller/CallerWorker.java | 19 ++- .../caller/CallerWorkflow.java | 2 +- .../caller/CallerWorkflowImpl.java | 20 ++- .../caller/EndpointResults.java | 6 +- .../{ => codec}/AesGcmCodec.java | 10 +- .../{ => codec}/NexusCodec.java | 23 ++-- .../{ => codec}/NexusEncoding.java | 2 +- .../codec/PropagatedEndpointCodec.java | 64 +++++++++ .../{ => codec}/ZlibCodec.java | 2 +- .../handler/AsyncHandlerWorker.java | 67 ++++++++++ .../CompressedEncryptedHandlerWorker.java | 20 ++- .../handler/EchoServiceImpl.java | 17 +++ .../handler/EchoWorkflow.java | 10 ++ .../handler/EchoWorkflowImpl.java | 8 ++ .../handler/EncryptedHandlerWorker.java | 18 ++- .../NexusEndpointContextPropagator.java | 51 ++++++++ .../propagation/NexusEndpointInterceptor.java | 36 ++++++ .../service/EchoService.java | 4 + .../{ => codec}/NexusCodecTest.java | 122 +++++++++++++++++- 22 files changed, 530 insertions(+), 52 deletions(-) rename core/src/main/java/io/temporal/samples/nexusserializationcontext/{ => codec}/AesGcmCodec.java (91%) rename core/src/main/java/io/temporal/samples/nexusserializationcontext/{ => codec}/NexusCodec.java (71%) rename core/src/main/java/io/temporal/samples/nexusserializationcontext/{ => codec}/NexusEncoding.java (81%) create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/PropagatedEndpointCodec.java rename core/src/main/java/io/temporal/samples/nexusserializationcontext/{ => codec}/ZlibCodec.java (97%) create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/AsyncHandlerWorker.java create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EchoWorkflow.java create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EchoWorkflowImpl.java create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/propagation/NexusEndpointContextPropagator.java create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/propagation/NexusEndpointInterceptor.java rename core/src/test/java/io/temporal/samples/nexusserializationcontext/{ => codec}/NexusCodecTest.java (52%) diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md b/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md index f84700b7..13465115 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md @@ -1,22 +1,25 @@ # Nexus serialization context -This sample calls the same synchronous Nexus operation through two endpoints. -Each endpoint routes to its own handler namespace and worker. The caller's +This sample calls two synchronous and two asynchronous Nexus operations through +four endpoints. The synchronous endpoints have separate handler workers. The two +asynchronous endpoints share a handler namespace, task queue, and worker. The caller's `NexusCodec` uses `NexusSerializationContext` to select the `PayloadCodec` registered for each endpoint: -- One endpoint compresses with zlib, then encrypts with AES-GCM using Key A. -- The other endpoint encrypts with AES-GCM using Key B. +- The first synchronous endpoint compresses with zlib, then encrypts with AES-GCM using Key A. +- The second synchronous endpoint encrypts with AES-GCM using Key B. +- One asynchronous endpoint encrypts with AES-GCM using Key C. +- The other asynchronous endpoint compresses with zlib, then encrypts with AES-GCM using Key D. -The caller configures both codecs. Each handler worker uses its own codec and key -without selecting by endpoint. +The caller configures all four codecs. The synchronous handler workers each use a fixed +key. The shared asynchronous worker has both Keys C and D and selects a codec by endpoint. -The caller schedules both operations before waiting for their results, so each +The caller schedules all four operations before waiting for their results, so each result must be decoded using the context of its own endpoint. The caller's `NexusCodec` leaves non-Nexus payloads unchanged. -The outer encrypted payload stores the endpoint name in `nexus-endpoint` metadata, -alongside `binary/nexus-aes-gcm` and a sample key ID (`key-a` or `key-b`). A Codec +The outer encrypted payload stores the endpoint name in `nexus-endpoint-name` metadata, +alongside `binary/nexus-aes-gcm` and a sample key ID (`key-a`, `key-b`, `key-c`, or `key-d`). A Codec Server can use the endpoint name to select the matching key and decompression chain without SDK context. @@ -25,17 +28,33 @@ For the compressed endpoint, the outer payload's decoded metadata looks like: ```text encoding: binary/nexus-aes-gcm encryption-key-id: key-a -nexus-endpoint: nexus-serialization-compressed-encrypted +nexus-endpoint-name: nexus-serialization-compressed-encrypted ``` +The asynchronous results have `encryption-key-id: key-c` or `key-d` and their respective +endpoint names in the outer payload metadata. + `NexusSerializationContext` works end to end for synchronous Nexus operations. -The final result of an asynchronous operation does not receive `NexusSerializationContext`. +For an asynchronous operation, the handler's final result is serialized as a workflow +result and does not receive `NexusSerializationContext`. This sample's +`NexusEndpointInterceptor` captures the endpoint when the handler starts the backing +workflow. `NexusEndpointContextPropagator` saves it in the workflow headers and restores +it on the workflow thread. The shared worker's `PropagatedEndpointCodec` uses the endpoint +to choose Key C or the Key D compression and encryption chain when encoding the workflow +result. Its `AesGcmCodec` also includes the endpoint name in the encrypted result metadata. +The caller uses `NexusSerializationContext` to decode each asynchronous result. + +This workaround covers asynchronous operations backed by workflows. If the propagated +endpoint is missing, the shared worker's codec rejects the payload instead of leaving +it unencrypted. The shared service implementation exposes both operations on every +worker, although the caller invokes `echoAsync` only through the two async endpoints. The hard-coded keys are only for this local example. For production encryption, use a secure key store, as in the [AWS Encryption SDK sample](../keymanagementencryption/awsencryptionsdk/README.md). -Requires Java SDK 1.40.0 or later and a Temporal server with Nexus enabled. +Requires Java SDK 1.40.0 or later and Temporal Server 1.30.0 or later with Nexus enabled +so the handler can read the endpoint name. ## Run locally @@ -51,6 +70,7 @@ In another terminal, create the namespaces and endpoints: temporal operator namespace create --namespace nexus-serialization-caller temporal operator namespace create --namespace nexus-serialization-key-a-handler temporal operator namespace create --namespace nexus-serialization-key-b-handler +temporal operator namespace create --namespace nexus-serialization-async-handler temporal operator nexus endpoint create \ --name nexus-serialization-compressed-encrypted \ --target-namespace nexus-serialization-key-a-handler \ @@ -59,6 +79,14 @@ temporal operator nexus endpoint create \ --name nexus-serialization-encrypted \ --target-namespace nexus-serialization-key-b-handler \ --target-task-queue nexus-serialization-key-b-handler +temporal operator nexus endpoint create \ + --name nexus-serialization-async-encrypted \ + --target-namespace nexus-serialization-async-handler \ + --target-task-queue nexus-serialization-async-handler +temporal operator nexus endpoint create \ + --name nexus-serialization-async-compressed-encrypted \ + --target-namespace nexus-serialization-async-handler \ + --target-task-queue nexus-serialization-async-handler ``` Run each of the following in its own terminal from the repository root: @@ -73,6 +101,11 @@ Run each of the following in its own terminal from the repository root: --args="-namespace nexus-serialization-key-b-handler" ``` +```bash +./gradlew -q :core:execute -PmainClass=io.temporal.samples.nexusserializationcontext.handler.AsyncHandlerWorker \ + --args="-namespace nexus-serialization-async-handler" +``` + ```bash ./gradlew -q :core:execute -PmainClass=io.temporal.samples.nexusserializationcontext.caller.CallerWorker \ --args="-namespace nexus-serialization-caller" @@ -86,6 +119,8 @@ Run each of the following in its own terminal from the repository root: The starter will print: ```text -Compressed and encrypted endpoint result: Hello from Nexus -Encrypted endpoint result: Hello from Nexus +Compressed and encrypted endpoint sync result: Hello from Nexus +Encrypted endpoint sync result: Hello from Nexus +Async encrypted endpoint result: Hello from Nexus +Async compressed and encrypted endpoint result: Hello from Nexus ``` diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java index b557124e..5f0e9448 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java @@ -4,13 +4,20 @@ public final class SampleConfig { public static final String COMPRESSED_ENCRYPTED_ENDPOINT = "nexus-serialization-compressed-encrypted"; public static final String ENCRYPTED_ENDPOINT = "nexus-serialization-encrypted"; + public static final String ASYNC_ENCRYPTED_ENDPOINT = "nexus-serialization-async-encrypted"; + public static final String ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT = + "nexus-serialization-async-compressed-encrypted"; public static final String ENDPOINT_METADATA_KEY = "nexus-endpoint-name"; public static final String KEY_A_ID = "key-a"; public static final String KEY_B_ID = "key-b"; + public static final String KEY_C_ID = "key-c"; + public static final String KEY_D_ID = "key-d"; // Hard-coded keys are only for this local sample. public static final String KEY_A_VALUE = "sample-key-A-123"; public static final String KEY_B_VALUE = "sample-key-B-123"; + public static final String KEY_C_VALUE = "sample-key-C-123"; + public static final String KEY_D_VALUE = "sample-key-D-123"; private SampleConfig() {} } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerStarter.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerStarter.java index fc09b03b..3611c146 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerStarter.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerStarter.java @@ -16,9 +16,14 @@ public static void main(String[] args) { CallerWorkflow.class, WorkflowOptions.newBuilder().setTaskQueue(CallerWorker.TASK_QUEUE).build()); - EndpointResults results = workflow.echoThroughBothEndpoints("Hello from Nexus"); + EndpointResults results = workflow.echoThroughEndpoints("Hello from Nexus"); System.out.println( - "Compressed and encrypted endpoint result: " + results.compressedEncryptedResult()); - System.out.println("Encrypted endpoint result: " + results.encryptedResult()); + "Compressed and encrypted endpoint sync result: " + + results.compressedEncryptedSyncResult()); + System.out.println("Encrypted endpoint sync result: " + results.encryptedSyncResult()); + System.out.println("Async encrypted endpoint result: " + results.asyncEncryptedResult()); + System.out.println( + "Async compressed and encrypted endpoint result: " + + results.asyncCompressedEncryptedResult()); } } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorker.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorker.java index e9a1ff90..5574e162 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorker.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorker.java @@ -8,10 +8,10 @@ import io.temporal.payload.codec.ChainCodec; import io.temporal.payload.codec.PayloadCodec; import io.temporal.samples.nexus.options.ClientOptions; -import io.temporal.samples.nexusserializationcontext.AesGcmCodec; -import io.temporal.samples.nexusserializationcontext.NexusCodec; import io.temporal.samples.nexusserializationcontext.SampleConfig; -import io.temporal.samples.nexusserializationcontext.ZlibCodec; +import io.temporal.samples.nexusserializationcontext.codec.AesGcmCodec; +import io.temporal.samples.nexusserializationcontext.codec.NexusCodec; +import io.temporal.samples.nexusserializationcontext.codec.ZlibCodec; import io.temporal.worker.Worker; import io.temporal.worker.WorkerFactory; import java.nio.charset.StandardCharsets; @@ -38,10 +38,17 @@ public static DataConverter dataConverter() { new SecretKeySpec(SampleConfig.KEY_A_VALUE.getBytes(StandardCharsets.UTF_8), "AES"); SecretKey keyB = new SecretKeySpec(SampleConfig.KEY_B_VALUE.getBytes(StandardCharsets.UTF_8), "AES"); + SecretKey keyC = + new SecretKeySpec(SampleConfig.KEY_C_VALUE.getBytes(StandardCharsets.UTF_8), "AES"); + SecretKey keyD = + new SecretKeySpec(SampleConfig.KEY_D_VALUE.getBytes(StandardCharsets.UTF_8), "AES"); // ChainCodec encodes last to first: compress, then encrypt. PayloadCodec compressedEncrypted = new ChainCodec(List.of(new AesGcmCodec(SampleConfig.KEY_A_ID, keyA), new ZlibCodec())); PayloadCodec encrypted = new AesGcmCodec(SampleConfig.KEY_B_ID, keyB); + PayloadCodec asyncEncrypted = new AesGcmCodec(SampleConfig.KEY_C_ID, keyC); + PayloadCodec asyncCompressedEncrypted = + new ChainCodec(List.of(new AesGcmCodec(SampleConfig.KEY_D_ID, keyD), new ZlibCodec())); return new CodecDataConverter( DefaultDataConverter.newDefaultInstance(), List.of( @@ -50,6 +57,10 @@ public static DataConverter dataConverter() { SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT, compressedEncrypted, SampleConfig.ENCRYPTED_ENDPOINT, - encrypted)))); + encrypted, + SampleConfig.ASYNC_ENCRYPTED_ENDPOINT, + asyncEncrypted, + SampleConfig.ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT, + asyncCompressedEncrypted)))); } } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflow.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflow.java index b0b27c71..a7bbe109 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflow.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflow.java @@ -6,5 +6,5 @@ @WorkflowInterface public interface CallerWorkflow { @WorkflowMethod - EndpointResults echoThroughBothEndpoints(String message); + EndpointResults echoThroughEndpoints(String message); } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflowImpl.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflowImpl.java index e8d851be..f56eb5b0 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflowImpl.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflowImpl.java @@ -10,17 +10,27 @@ public class CallerWorkflowImpl implements CallerWorkflow { @Override - public EndpointResults echoThroughBothEndpoints(String message) { + public EndpointResults echoThroughEndpoints(String message) { EchoService compressedEncryptedService = serviceFor(SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT); EchoService encryptedService = serviceFor(SampleConfig.ENCRYPTED_ENDPOINT); + EchoService asyncEncryptedService = serviceFor(SampleConfig.ASYNC_ENCRYPTED_ENDPOINT); + EchoService asyncCompressedEncryptedService = + serviceFor(SampleConfig.ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT); - // Start both before awaiting either result. Each result must keep its own endpoint context. - NexusOperationHandle compressedEncryptedOperation = + // Start all operations before awaiting results. Each result keeps its endpoint context. + NexusOperationHandle compressedEncryptedSync = Workflow.startNexusOperation(compressedEncryptedService::echo, message); - NexusOperationHandle encryptedOperation = + NexusOperationHandle encryptedSync = Workflow.startNexusOperation(encryptedService::echo, message); + NexusOperationHandle asyncEncrypted = + Workflow.startNexusOperation(asyncEncryptedService::echoAsync, message); + NexusOperationHandle asyncCompressedEncrypted = + Workflow.startNexusOperation(asyncCompressedEncryptedService::echoAsync, message); return new EndpointResults( - compressedEncryptedOperation.getResult().get(), encryptedOperation.getResult().get()); + compressedEncryptedSync.getResult().get(), + encryptedSync.getResult().get(), + asyncEncrypted.getResult().get(), + asyncCompressedEncrypted.getResult().get()); } private static EchoService serviceFor(String endpoint) { diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/EndpointResults.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/EndpointResults.java index f283c716..ed623d49 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/EndpointResults.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/EndpointResults.java @@ -1,3 +1,7 @@ package io.temporal.samples.nexusserializationcontext.caller; -public record EndpointResults(String compressedEncryptedResult, String encryptedResult) {} +public record EndpointResults( + String compressedEncryptedSyncResult, + String encryptedSyncResult, + String asyncEncryptedResult, + String asyncCompressedEncryptedResult) {} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/AesGcmCodec.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/AesGcmCodec.java similarity index 91% rename from core/src/main/java/io/temporal/samples/nexusserializationcontext/AesGcmCodec.java rename to core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/AesGcmCodec.java index 8fdcc530..b1893fea 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/AesGcmCodec.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/AesGcmCodec.java @@ -1,4 +1,4 @@ -package io.temporal.samples.nexusserializationcontext; +package io.temporal.samples.nexusserializationcontext.codec; import com.google.protobuf.ByteString; import com.google.protobuf.InvalidProtocolBufferException; @@ -8,6 +8,8 @@ import io.temporal.payload.codec.PayloadCodecException; import io.temporal.payload.context.NexusSerializationContext; import io.temporal.payload.context.SerializationContext; +import io.temporal.samples.nexusserializationcontext.SampleConfig; +import io.temporal.samples.nexusserializationcontext.propagation.NexusEndpointContextPropagator; import java.nio.ByteBuffer; import java.security.GeneralSecurityException; import java.security.SecureRandom; @@ -56,6 +58,8 @@ public PayloadCodec withContext(@Nonnull SerializationContext context) { @Override @Nonnull public List encode(@Nonnull List payloads) { + String endpointName = + StringUtils.defaultIfBlank(endpoint, NexusEndpointContextPropagator.currentEndpoint()); List encoded = new ArrayList<>(payloads.size()); for (Payload payload : payloads) { Payload.Builder encrypted = @@ -65,9 +69,9 @@ public List encode(@Nonnull List payloads) { ByteString.copyFromUtf8(NexusEncoding.AES_GCM.encodingName())) .putMetadata(KEY_ID_METADATA_KEY, ByteString.copyFromUtf8(keyId)) .setData(ByteString.copyFrom(encrypt(payload.toByteArray()))); - if (StringUtils.isNotBlank(endpoint)) { + if (StringUtils.isNotBlank(endpointName)) { encrypted.putMetadata( - SampleConfig.ENDPOINT_METADATA_KEY, ByteString.copyFromUtf8(endpoint)); + SampleConfig.ENDPOINT_METADATA_KEY, ByteString.copyFromUtf8(endpointName)); } encoded.add(encrypted.build()); } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusCodec.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodec.java similarity index 71% rename from core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusCodec.java rename to core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodec.java index d94d2fa2..ad8ec7a9 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusCodec.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodec.java @@ -1,4 +1,4 @@ -package io.temporal.samples.nexusserializationcontext; +package io.temporal.samples.nexusserializationcontext.codec; import io.temporal.api.common.v1.Payload; import io.temporal.payload.codec.PayloadCodec; @@ -9,7 +9,11 @@ import java.util.Map; import javax.annotation.Nonnull; -/** Selects a payload codec from the Nexus endpoint. */ +/** + * Selects a codec when the SDK provides {@link NexusSerializationContext}. The caller uses this for + * both synchronous and asynchronous Nexus requests and results. Non-Nexus workflow payloads pass + * through unchanged. + */ public final class NexusCodec implements PayloadCodec { private final Map codecsByEndpoint; @@ -23,12 +27,7 @@ public PayloadCodec withContext(@Nonnull SerializationContext context) { if (!(context instanceof NexusSerializationContext)) { return this; } - String endpoint = ((NexusSerializationContext) context).getEndpoint(); - PayloadCodec codec = codecsByEndpoint.get(endpoint); - if (codec == null) { - throw new PayloadCodecException("Unknown Nexus endpoint: " + endpoint); - } - return codec.withContext(context); + return codecFor(((NexusSerializationContext) context).getEndpoint()).withContext(context); } @Override @@ -42,4 +41,12 @@ public List encode(@Nonnull List payloads) { public List decode(@Nonnull List payloads) { return payloads; } + + private PayloadCodec codecFor(String endpoint) { + PayloadCodec codec = codecsByEndpoint.get(endpoint); + if (codec == null) { + throw new PayloadCodecException("Unknown Nexus endpoint: " + endpoint); + } + return codec; + } } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusEncoding.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/NexusEncoding.java similarity index 81% rename from core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusEncoding.java rename to core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/NexusEncoding.java index f641b31c..1a725589 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/NexusEncoding.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/NexusEncoding.java @@ -1,4 +1,4 @@ -package io.temporal.samples.nexusserializationcontext; +package io.temporal.samples.nexusserializationcontext.codec; enum NexusEncoding { AES_GCM("binary/nexus-aes-gcm"), diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/PropagatedEndpointCodec.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/PropagatedEndpointCodec.java new file mode 100644 index 00000000..c6567715 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/PropagatedEndpointCodec.java @@ -0,0 +1,64 @@ +package io.temporal.samples.nexusserializationcontext.codec; + +import io.temporal.api.common.v1.Payload; +import io.temporal.payload.codec.PayloadCodec; +import io.temporal.payload.codec.PayloadCodecException; +import io.temporal.payload.context.NexusSerializationContext; +import io.temporal.payload.context.SerializationContext; +import io.temporal.samples.nexusserializationcontext.propagation.NexusEndpointContextPropagator; +import java.util.List; +import java.util.Map; +import javax.annotation.Nonnull; +import org.apache.commons.lang.StringUtils; + +/** + * Selects a codec on the shared handler for workflow-backed asynchronous operations. The incoming + * Nexus request has {@link NexusSerializationContext}; the backing workflow's payloads and final + * result use the endpoint carried by the context propagator instead. + */ +public final class PropagatedEndpointCodec implements PayloadCodec { + private final Map codecsByEndpoint; + + public PropagatedEndpointCodec(Map codecsByEndpoint) { + this.codecsByEndpoint = Map.copyOf(codecsByEndpoint); + } + + @Override + @Nonnull + public PayloadCodec withContext(@Nonnull SerializationContext context) { + // The incoming Nexus request has this context; the backing workflow's result does not. + if (context instanceof NexusSerializationContext nexusContext) { + return codecFor(nexusContext.getEndpoint()).withContext(context); + } + return this; + } + + @Override + @Nonnull + public List encode(@Nonnull List payloads) { + return codecForCurrentEndpoint().encode(payloads); + } + + @Override + @Nonnull + public List decode(@Nonnull List payloads) { + return codecForCurrentEndpoint().decode(payloads); + } + + private PayloadCodec codecForCurrentEndpoint() { + // For backing workflow payloads, choose the codec from the propagated endpoint. + String endpoint = NexusEndpointContextPropagator.currentEndpoint(); + if (StringUtils.isBlank(endpoint)) { + throw new PayloadCodecException("Missing propagated Nexus endpoint"); + } + return codecFor(endpoint); + } + + private PayloadCodec codecFor(String endpoint) { + PayloadCodec codec = codecsByEndpoint.get(endpoint); + if (codec == null) { + throw new PayloadCodecException("Unknown Nexus endpoint: " + endpoint); + } + return codec; + } +} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/ZlibCodec.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/ZlibCodec.java similarity index 97% rename from core/src/main/java/io/temporal/samples/nexusserializationcontext/ZlibCodec.java rename to core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/ZlibCodec.java index 1a61da01..f7f350ab 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/ZlibCodec.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/ZlibCodec.java @@ -1,4 +1,4 @@ -package io.temporal.samples.nexusserializationcontext; +package io.temporal.samples.nexusserializationcontext.codec; import com.google.protobuf.ByteString; import com.google.protobuf.InvalidProtocolBufferException; diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/AsyncHandlerWorker.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/AsyncHandlerWorker.java new file mode 100644 index 00000000..be41708d --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/AsyncHandlerWorker.java @@ -0,0 +1,67 @@ +package io.temporal.samples.nexusserializationcontext.handler; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.converter.CodecDataConverter; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.payload.codec.ChainCodec; +import io.temporal.payload.codec.PayloadCodec; +import io.temporal.samples.nexus.options.ClientOptions; +import io.temporal.samples.nexusserializationcontext.SampleConfig; +import io.temporal.samples.nexusserializationcontext.codec.AesGcmCodec; +import io.temporal.samples.nexusserializationcontext.codec.PropagatedEndpointCodec; +import io.temporal.samples.nexusserializationcontext.codec.ZlibCodec; +import io.temporal.samples.nexusserializationcontext.propagation.NexusEndpointContextPropagator; +import io.temporal.samples.nexusserializationcontext.propagation.NexusEndpointInterceptor; +import io.temporal.worker.Worker; +import io.temporal.worker.WorkerFactory; +import io.temporal.worker.WorkerFactoryOptions; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +public class AsyncHandlerWorker { + private static final String TASK_QUEUE = "nexus-serialization-async-handler"; + + public static void main(String[] args) { + WorkflowClient client = + ClientOptions.getWorkflowClient( + args, + WorkflowClientOptions.newBuilder() + .setDataConverter(dataConverter()) + .setContextPropagators(List.of(new NexusEndpointContextPropagator()))); + WorkerFactory factory = + WorkerFactory.newInstance( + client, + WorkerFactoryOptions.newBuilder() + .setWorkerInterceptors(new NexusEndpointInterceptor()) + .build()); + Worker worker = factory.newWorker(TASK_QUEUE); + worker.registerWorkflowImplementationTypes(EchoWorkflowImpl.class); + worker.registerNexusServiceImplementation(new EchoServiceImpl()); + factory.start(); + } + + public static DataConverter dataConverter() { + SecretKey keyC = + new SecretKeySpec(SampleConfig.KEY_C_VALUE.getBytes(StandardCharsets.UTF_8), "AES"); + SecretKey keyD = + new SecretKeySpec(SampleConfig.KEY_D_VALUE.getBytes(StandardCharsets.UTF_8), "AES"); + PayloadCodec encrypted = new AesGcmCodec(SampleConfig.KEY_C_ID, keyC); + // ChainCodec encodes last to first: compress, then encrypt. + PayloadCodec compressedEncrypted = + new ChainCodec(List.of(new AesGcmCodec(SampleConfig.KEY_D_ID, keyD), new ZlibCodec())); + return new CodecDataConverter( + DefaultDataConverter.newDefaultInstance(), + List.of( + new PropagatedEndpointCodec( + Map.of( + SampleConfig.ASYNC_ENCRYPTED_ENDPOINT, + encrypted, + SampleConfig.ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT, + compressedEncrypted)))); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/CompressedEncryptedHandlerWorker.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/CompressedEncryptedHandlerWorker.java index 708593d0..025ec514 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/CompressedEncryptedHandlerWorker.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/CompressedEncryptedHandlerWorker.java @@ -7,11 +7,14 @@ import io.temporal.common.converter.DefaultDataConverter; import io.temporal.payload.codec.ChainCodec; import io.temporal.samples.nexus.options.ClientOptions; -import io.temporal.samples.nexusserializationcontext.AesGcmCodec; import io.temporal.samples.nexusserializationcontext.SampleConfig; -import io.temporal.samples.nexusserializationcontext.ZlibCodec; +import io.temporal.samples.nexusserializationcontext.codec.AesGcmCodec; +import io.temporal.samples.nexusserializationcontext.codec.ZlibCodec; +import io.temporal.samples.nexusserializationcontext.propagation.NexusEndpointContextPropagator; +import io.temporal.samples.nexusserializationcontext.propagation.NexusEndpointInterceptor; import io.temporal.worker.Worker; import io.temporal.worker.WorkerFactory; +import io.temporal.worker.WorkerFactoryOptions; import java.nio.charset.StandardCharsets; import java.util.List; import javax.crypto.SecretKey; @@ -23,9 +26,18 @@ public class CompressedEncryptedHandlerWorker { public static void main(String[] args) { WorkflowClient client = ClientOptions.getWorkflowClient( - args, WorkflowClientOptions.newBuilder().setDataConverter(dataConverter())); - WorkerFactory factory = WorkerFactory.newInstance(client); + args, + WorkflowClientOptions.newBuilder() + .setDataConverter(dataConverter()) + .setContextPropagators(List.of(new NexusEndpointContextPropagator()))); + WorkerFactory factory = + WorkerFactory.newInstance( + client, + WorkerFactoryOptions.newBuilder() + .setWorkerInterceptors(new NexusEndpointInterceptor()) + .build()); Worker worker = factory.newWorker(TASK_QUEUE); + worker.registerWorkflowImplementationTypes(EchoWorkflowImpl.class); worker.registerNexusServiceImplementation(new EchoServiceImpl()); factory.start(); } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EchoServiceImpl.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EchoServiceImpl.java index edab13d3..bd441bf0 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EchoServiceImpl.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EchoServiceImpl.java @@ -3,6 +3,9 @@ import io.nexusrpc.handler.OperationHandler; import io.nexusrpc.handler.OperationImpl; import io.nexusrpc.handler.ServiceImpl; +import io.temporal.client.WorkflowOptions; +import io.temporal.nexus.Nexus; +import io.temporal.nexus.WorkflowRunOperation; import io.temporal.samples.nexusserializationcontext.service.EchoService; @ServiceImpl(service = EchoService.class) @@ -11,4 +14,18 @@ public class EchoServiceImpl { public OperationHandler echo() { return OperationHandler.sync((ctx, details, message) -> message); } + + @OperationImpl + public OperationHandler echoAsync() { + return WorkflowRunOperation.fromWorkflowMethod( + (ctx, details, message) -> + Nexus.getOperationContext() + .getWorkflowClient() + .newWorkflowStub( + EchoWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId("nexus-echo-" + details.getRequestId()) + .build()) + ::echo); + } } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EchoWorkflow.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EchoWorkflow.java new file mode 100644 index 00000000..ce6d8f2e --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EchoWorkflow.java @@ -0,0 +1,10 @@ +package io.temporal.samples.nexusserializationcontext.handler; + +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +@WorkflowInterface +public interface EchoWorkflow { + @WorkflowMethod + String echo(String message); +} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EchoWorkflowImpl.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EchoWorkflowImpl.java new file mode 100644 index 00000000..03816609 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EchoWorkflowImpl.java @@ -0,0 +1,8 @@ +package io.temporal.samples.nexusserializationcontext.handler; + +public final class EchoWorkflowImpl implements EchoWorkflow { + @Override + public String echo(String message) { + return message; + } +} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EncryptedHandlerWorker.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EncryptedHandlerWorker.java index 85fdbf03..9fb4bb4c 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EncryptedHandlerWorker.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EncryptedHandlerWorker.java @@ -6,10 +6,13 @@ import io.temporal.common.converter.DataConverter; import io.temporal.common.converter.DefaultDataConverter; import io.temporal.samples.nexus.options.ClientOptions; -import io.temporal.samples.nexusserializationcontext.AesGcmCodec; import io.temporal.samples.nexusserializationcontext.SampleConfig; +import io.temporal.samples.nexusserializationcontext.codec.AesGcmCodec; +import io.temporal.samples.nexusserializationcontext.propagation.NexusEndpointContextPropagator; +import io.temporal.samples.nexusserializationcontext.propagation.NexusEndpointInterceptor; import io.temporal.worker.Worker; import io.temporal.worker.WorkerFactory; +import io.temporal.worker.WorkerFactoryOptions; import java.nio.charset.StandardCharsets; import java.util.List; import javax.crypto.SecretKey; @@ -21,9 +24,18 @@ public class EncryptedHandlerWorker { public static void main(String[] args) { WorkflowClient client = ClientOptions.getWorkflowClient( - args, WorkflowClientOptions.newBuilder().setDataConverter(dataConverter())); - WorkerFactory factory = WorkerFactory.newInstance(client); + args, + WorkflowClientOptions.newBuilder() + .setDataConverter(dataConverter()) + .setContextPropagators(List.of(new NexusEndpointContextPropagator()))); + WorkerFactory factory = + WorkerFactory.newInstance( + client, + WorkerFactoryOptions.newBuilder() + .setWorkerInterceptors(new NexusEndpointInterceptor()) + .build()); Worker worker = factory.newWorker(TASK_QUEUE); + worker.registerWorkflowImplementationTypes(EchoWorkflowImpl.class); worker.registerNexusServiceImplementation(new EchoServiceImpl()); factory.start(); } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/propagation/NexusEndpointContextPropagator.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/propagation/NexusEndpointContextPropagator.java new file mode 100644 index 00000000..394275f1 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/propagation/NexusEndpointContextPropagator.java @@ -0,0 +1,51 @@ +package io.temporal.samples.nexusserializationcontext.propagation; + +import io.temporal.api.common.v1.Payload; +import io.temporal.common.context.ContextPropagator; +import io.temporal.common.converter.DataConverter; +import io.temporal.samples.nexusserializationcontext.SampleConfig; +import java.util.Map; +import org.slf4j.MDC; + +/** Carries the Nexus endpoint from a handler into the workflow it starts. */ +public final class NexusEndpointContextPropagator implements ContextPropagator { + private static final DataConverter CONVERTER = DataConverter.getDefaultInstance(); + + public static String currentEndpoint() { + return MDC.get(SampleConfig.ENDPOINT_METADATA_KEY); + } + + @Override + public String getName() { + return NexusEndpointContextPropagator.class.getName(); + } + + @Override + public Object getCurrentContext() { + return currentEndpoint(); + } + + @Override + public void setCurrentContext(Object context) { + if (context == null) { + MDC.remove(SampleConfig.ENDPOINT_METADATA_KEY); + } else { + MDC.put(SampleConfig.ENDPOINT_METADATA_KEY, (String) context); + } + } + + @Override + public Map serializeContext(Object context) { + if (context == null) { + return Map.of(); + } + return Map.of( + SampleConfig.ENDPOINT_METADATA_KEY, CONVERTER.toPayload((String) context).orElseThrow()); + } + + @Override + public Object deserializeContext(Map header) { + Payload payload = header.get(SampleConfig.ENDPOINT_METADATA_KEY); + return payload == null ? null : CONVERTER.fromPayload(payload, String.class, String.class); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/propagation/NexusEndpointInterceptor.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/propagation/NexusEndpointInterceptor.java new file mode 100644 index 00000000..acf05000 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/propagation/NexusEndpointInterceptor.java @@ -0,0 +1,36 @@ +package io.temporal.samples.nexusserializationcontext.propagation; + +import io.nexusrpc.OperationException; +import io.nexusrpc.handler.OperationContext; +import io.temporal.common.interceptors.NexusOperationInboundCallsInterceptor; +import io.temporal.common.interceptors.NexusOperationInboundCallsInterceptorBase; +import io.temporal.common.interceptors.WorkerInterceptorBase; +import io.temporal.nexus.Nexus; +import io.temporal.samples.nexusserializationcontext.SampleConfig; +import org.slf4j.MDC; + +/** Makes the endpoint available while a Nexus handler starts its backing workflow. */ +public final class NexusEndpointInterceptor extends WorkerInterceptorBase { + @Override + public NexusOperationInboundCallsInterceptor interceptNexusOperation( + OperationContext context, NexusOperationInboundCallsInterceptor next) { + return new NexusOperationInboundCallsInterceptorBase(next) { + @Override + public StartOperationOutput startOperation(StartOperationInput input) + throws OperationException { + String previousEndpoint = NexusEndpointContextPropagator.currentEndpoint(); + String endpoint = Nexus.getOperationContext().getInfo().getEndpoint(); + try { + MDC.put(SampleConfig.ENDPOINT_METADATA_KEY, endpoint); + return super.startOperation(input); + } finally { + if (previousEndpoint == null) { + MDC.remove(SampleConfig.ENDPOINT_METADATA_KEY); + } else { + MDC.put(SampleConfig.ENDPOINT_METADATA_KEY, previousEndpoint); + } + } + } + }; + } +} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/service/EchoService.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/service/EchoService.java index f389d6e4..738b734c 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/service/EchoService.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/service/EchoService.java @@ -7,7 +7,11 @@ public interface EchoService { String SERVICE_NAME = "EchoService"; String ECHO_OPERATION_NAME = "echo"; + String ECHO_ASYNC_OPERATION_NAME = "echoAsync"; @Operation(name = ECHO_OPERATION_NAME) String echo(String message); + + @Operation(name = ECHO_ASYNC_OPERATION_NAME) + String echoAsync(String message); } diff --git a/core/src/test/java/io/temporal/samples/nexusserializationcontext/NexusCodecTest.java b/core/src/test/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodecTest.java similarity index 52% rename from core/src/test/java/io/temporal/samples/nexusserializationcontext/NexusCodecTest.java rename to core/src/test/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodecTest.java index 7af69172..d7904b52 100644 --- a/core/src/test/java/io/temporal/samples/nexusserializationcontext/NexusCodecTest.java +++ b/core/src/test/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodecTest.java @@ -1,4 +1,4 @@ -package io.temporal.samples.nexusserializationcontext; +package io.temporal.samples.nexusserializationcontext.codec; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -11,9 +11,13 @@ import io.temporal.payload.codec.PayloadCodecException; import io.temporal.payload.context.NexusSerializationContext; import io.temporal.payload.context.SerializationContext; +import io.temporal.payload.context.WorkflowSerializationContext; +import io.temporal.samples.nexusserializationcontext.SampleConfig; import io.temporal.samples.nexusserializationcontext.caller.CallerWorker; +import io.temporal.samples.nexusserializationcontext.handler.AsyncHandlerWorker; import io.temporal.samples.nexusserializationcontext.handler.CompressedEncryptedHandlerWorker; import io.temporal.samples.nexusserializationcontext.handler.EncryptedHandlerWorker; +import io.temporal.samples.nexusserializationcontext.propagation.NexusEndpointContextPropagator; import io.temporal.samples.nexusserializationcontext.service.EchoService; import org.junit.jupiter.api.Test; @@ -22,9 +26,13 @@ class NexusCodecTest { void encryptsWithTheKeyForEachEndpoint() { DataConverter keyAConverter = converterFor(SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT); DataConverter keyBConverter = converterFor(SampleConfig.ENCRYPTED_ENDPOINT); + DataConverter keyCConverter = converterFor(SampleConfig.ASYNC_ENCRYPTED_ENDPOINT); + DataConverter keyDConverter = converterFor(SampleConfig.ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT); Payload keyAPayload = keyAConverter.toPayload("hello").orElseThrow(); Payload keyBPayload = keyBConverter.toPayload("hello").orElseThrow(); + Payload keyCPayload = keyCConverter.toPayload("hello").orElseThrow(); + Payload keyDPayload = keyDConverter.toPayload("hello").orElseThrow(); assertEquals( NexusEncoding.AES_GCM.encodingName(), @@ -32,23 +40,49 @@ void encryptsWithTheKeyForEachEndpoint() { assertEquals( NexusEncoding.AES_GCM.encodingName(), keyBPayload.getMetadataOrThrow(EncodingKeys.METADATA_ENCODING_KEY).toStringUtf8()); + assertEquals( + NexusEncoding.AES_GCM.encodingName(), + keyCPayload.getMetadataOrThrow(EncodingKeys.METADATA_ENCODING_KEY).toStringUtf8()); + assertEquals( + NexusEncoding.AES_GCM.encodingName(), + keyDPayload.getMetadataOrThrow(EncodingKeys.METADATA_ENCODING_KEY).toStringUtf8()); assertEquals( SampleConfig.KEY_A_ID, keyAPayload.getMetadataOrThrow(AesGcmCodec.KEY_ID_METADATA_KEY).toStringUtf8()); assertEquals( SampleConfig.KEY_B_ID, keyBPayload.getMetadataOrThrow(AesGcmCodec.KEY_ID_METADATA_KEY).toStringUtf8()); + assertEquals( + SampleConfig.KEY_C_ID, + keyCPayload.getMetadataOrThrow(AesGcmCodec.KEY_ID_METADATA_KEY).toStringUtf8()); + assertEquals( + SampleConfig.KEY_D_ID, + keyDPayload.getMetadataOrThrow(AesGcmCodec.KEY_ID_METADATA_KEY).toStringUtf8()); assertEquals( SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT, keyAPayload.getMetadataOrThrow(SampleConfig.ENDPOINT_METADATA_KEY).toStringUtf8()); assertEquals( SampleConfig.ENCRYPTED_ENDPOINT, keyBPayload.getMetadataOrThrow(SampleConfig.ENDPOINT_METADATA_KEY).toStringUtf8()); + assertEquals( + SampleConfig.ASYNC_ENCRYPTED_ENDPOINT, + keyCPayload.getMetadataOrThrow(SampleConfig.ENDPOINT_METADATA_KEY).toStringUtf8()); + assertEquals( + SampleConfig.ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT, + keyDPayload.getMetadataOrThrow(SampleConfig.ENDPOINT_METADATA_KEY).toStringUtf8()); assertEquals("hello", keyAConverter.fromPayload(keyAPayload, String.class, String.class)); assertEquals("hello", keyBConverter.fromPayload(keyBPayload, String.class, String.class)); + assertEquals("hello", keyCConverter.fromPayload(keyCPayload, String.class, String.class)); + assertEquals("hello", keyDConverter.fromPayload(keyDPayload, String.class, String.class)); assertThrows( PayloadCodecException.class, () -> keyAConverter.fromPayload(keyBPayload, String.class, String.class)); + assertThrows( + PayloadCodecException.class, + () -> keyBConverter.fromPayload(keyCPayload, String.class, String.class)); + assertThrows( + PayloadCodecException.class, + () -> keyCConverter.fromPayload(keyDPayload, String.class, String.class)); Payload keyAPayloadMarkedAsB = keyAPayload.toBuilder() .putMetadata( @@ -68,6 +102,14 @@ void compressesBeforeEncryptingForTheFirstEndpoint() { converterFor(SampleConfig.ENCRYPTED_ENDPOINT).toPayload(message).orElseThrow(); assertTrue(compressed.getData().size() < encryptedOnly.getData().size()); + + Payload asyncCompressed = + converterFor(SampleConfig.ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT) + .toPayload(message) + .orElseThrow(); + Payload asyncEncrypted = + converterFor(SampleConfig.ASYNC_ENCRYPTED_ENDPOINT).toPayload(message).orElseThrow(); + assertTrue(asyncCompressed.getData().size() < asyncEncrypted.getData().size()); } @Test @@ -95,7 +137,7 @@ void rejectsUnknownEndpoints() { } @Test - void eachHandlerUsesItsOwnCodecWithoutEndpointContext() { + void synchronousHandlersUseTheirOwnCodecWithoutEndpointContext() { DataConverter callerA = converterFor(SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT); DataConverter callerB = converterFor(SampleConfig.ENCRYPTED_ENDPOINT); DataConverter handlerA = CompressedEncryptedHandlerWorker.dataConverter(); @@ -136,12 +178,84 @@ void eachHandlerUsesItsOwnCodecWithoutEndpointContext() { () -> handlerB.fromPayload(requestA, String.class, String.class)); } + @Test + void sharedAsyncHandlerDecodesRequestsUsingNexusContext() { + DataConverter handlerC = + AsyncHandlerWorker.dataConverter() + .withContext(contextFor(SampleConfig.ASYNC_ENCRYPTED_ENDPOINT)); + DataConverter handlerD = + AsyncHandlerWorker.dataConverter() + .withContext(contextFor(SampleConfig.ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT)); + Payload requestC = + converterFor(SampleConfig.ASYNC_ENCRYPTED_ENDPOINT).toPayload("hello").orElseThrow(); + Payload requestD = + converterFor(SampleConfig.ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT) + .toPayload("hello") + .orElseThrow(); + + assertEquals("hello", handlerC.fromPayload(requestC, String.class, String.class)); + assertEquals("hello", handlerD.fromPayload(requestD, String.class, String.class)); + assertThrows( + PayloadCodecException.class, + () -> handlerC.fromPayload(requestD, String.class, String.class)); + } + + @Test + void propagatedEndpointSelectsAsyncWorkflowResultCodec() { + NexusEndpointContextPropagator propagator = new NexusEndpointContextPropagator(); + try { + propagator.setCurrentContext(null); + DataConverter handler = + AsyncHandlerWorker.dataConverter() + .withContext(new WorkflowSerializationContext("handler-namespace", "echo-workflow")); + assertThrows(PayloadCodecException.class, () -> handler.toPayload("reply")); + + Payload resultC = + asyncResultFor( + propagator, handler, SampleConfig.ASYNC_ENCRYPTED_ENDPOINT, SampleConfig.KEY_C_ID); + Payload resultD = + asyncResultFor( + propagator, + handler, + SampleConfig.ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT, + SampleConfig.KEY_D_ID); + assertTrue(resultD.getData().size() < resultC.getData().size()); + } finally { + propagator.setCurrentContext(null); + } + } + + private static Payload asyncResultFor( + NexusEndpointContextPropagator propagator, + DataConverter handler, + String endpoint, + String keyId) { + propagator.setCurrentContext(endpoint); + var header = propagator.serializeContext(propagator.getCurrentContext()); + propagator.setCurrentContext(null); + propagator.setCurrentContext(propagator.deserializeContext(header)); + + String message = "reply ".repeat(100); + Payload result = handler.toPayload(message).orElseThrow(); + assertEquals( + endpoint, result.getMetadataOrThrow(SampleConfig.ENDPOINT_METADATA_KEY).toStringUtf8()); + assertEquals(keyId, result.getMetadataOrThrow(AesGcmCodec.KEY_ID_METADATA_KEY).toStringUtf8()); + assertEquals(message, converterFor(endpoint).fromPayload(result, String.class, String.class)); + assertEquals(message, handler.fromPayload(result, String.class, String.class)); + propagator.setCurrentContext(null); + return result; + } + private static DataConverter converterFor(String endpoint) { return CallerWorker.dataConverter().withContext(contextFor(endpoint)); } private static NexusSerializationContext contextFor(String endpoint) { - return new NexusSerializationContext( - endpoint, EchoService.SERVICE_NAME, EchoService.ECHO_OPERATION_NAME); + String operation = + SampleConfig.ASYNC_ENCRYPTED_ENDPOINT.equals(endpoint) + || SampleConfig.ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT.equals(endpoint) + ? EchoService.ECHO_ASYNC_OPERATION_NAME + : EchoService.ECHO_OPERATION_NAME; + return new NexusSerializationContext(endpoint, EchoService.SERVICE_NAME, operation); } } From d31656f530e9456dc129ffafaf3174da190c81f5 Mon Sep 17 00:00:00 2001 From: Joshua Frenchwood Date: Thu, 1 Oct 2026 15:53:41 -0500 Subject: [PATCH 7/8] Encrypting caller payload in sample --- .../nexusserializationcontext/README.md | 24 +++++++++------ .../SampleConfig.java | 2 ++ .../caller/CallerWorker.java | 5 +++- .../codec/AesGcmCodec.java | 6 ++-- .../codec/NexusCodec.java | 21 +++++++------ .../codec/NexusCodecTest.java | 30 ++++++++++++++----- 6 files changed, 59 insertions(+), 29 deletions(-) diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md b/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md index 13465115..0ff9a69a 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md @@ -4,21 +4,24 @@ This sample calls two synchronous and two asynchronous Nexus operations through four endpoints. The synchronous endpoints have separate handler workers. The two asynchronous endpoints share a handler namespace, task queue, and worker. The caller's `NexusCodec` uses `NexusSerializationContext` to select the `PayloadCodec` -registered for each endpoint: +registered for each endpoint. The sample uses these keys: -- The first synchronous endpoint compresses with zlib, then encrypts with AES-GCM using Key A. -- The second synchronous endpoint encrypts with AES-GCM using Key B. -- One asynchronous endpoint encrypts with AES-GCM using Key C. -- The other asynchronous endpoint compresses with zlib, then encrypts with AES-GCM using Key D. +- Key A: compresses with zlib, then encrypts the first synchronous endpoint's payloads. +- Key B: encrypts the second synchronous endpoint's payloads. +- Key C: encrypts one asynchronous endpoint's payloads. +- Key D: compresses with zlib, then encrypts the other asynchronous endpoint's payloads. +- Key E: encrypts the caller workflow's input and final result. -The caller configures all four codecs. The synchronous handler workers each use a fixed -key. The shared asynchronous worker has both Keys C and D and selects a codec by endpoint. +The caller configures all four endpoint codecs and a separate codec for its own +workflow input and result. The synchronous handler workers each use a fixed key. The +shared asynchronous worker has both Keys C and D and selects a codec by endpoint. The caller schedules all four operations before waiting for their results, so each result must be decoded using the context of its own endpoint. The caller's -`NexusCodec` leaves non-Nexus payloads unchanged. +`NexusCodec` uses Key E for its own workflow payloads, which have no Nexus endpoint. -The outer encrypted payload stores the endpoint name in `nexus-endpoint-name` metadata, +For the four Nexus endpoints, the outer encrypted payload stores the endpoint name in +`nexus-endpoint-name` metadata, alongside `binary/nexus-aes-gcm` and a sample key ID (`key-a`, `key-b`, `key-c`, or `key-d`). A Codec Server can use the endpoint name to select the matching key and decompression chain without SDK context. @@ -33,6 +36,9 @@ nexus-endpoint-name: nexus-serialization-compressed-encrypted The asynchronous results have `encryption-key-id: key-c` or `key-d` and their respective endpoint names in the outer payload metadata. +The caller workflow's input and final result use `encryption-key-id: key-e`; they do not +have endpoint metadata. The starter uses the same converter as the caller worker, so it +can decode the final result before printing it. `NexusSerializationContext` works end to end for synchronous Nexus operations. For an asynchronous operation, the handler's final result is serialized as a workflow diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java index 5f0e9448..8ab49d9a 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java @@ -12,12 +12,14 @@ public final class SampleConfig { public static final String KEY_B_ID = "key-b"; public static final String KEY_C_ID = "key-c"; public static final String KEY_D_ID = "key-d"; + public static final String KEY_E_ID = "key-e"; // Hard-coded keys are only for this local sample. public static final String KEY_A_VALUE = "sample-key-A-123"; public static final String KEY_B_VALUE = "sample-key-B-123"; public static final String KEY_C_VALUE = "sample-key-C-123"; public static final String KEY_D_VALUE = "sample-key-D-123"; + public static final String KEY_E_VALUE = "sample-key-E-123"; private SampleConfig() {} } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorker.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorker.java index 5574e162..a24fdc3a 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorker.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorker.java @@ -42,6 +42,8 @@ public static DataConverter dataConverter() { new SecretKeySpec(SampleConfig.KEY_C_VALUE.getBytes(StandardCharsets.UTF_8), "AES"); SecretKey keyD = new SecretKeySpec(SampleConfig.KEY_D_VALUE.getBytes(StandardCharsets.UTF_8), "AES"); + SecretKey keyE = + new SecretKeySpec(SampleConfig.KEY_E_VALUE.getBytes(StandardCharsets.UTF_8), "AES"); // ChainCodec encodes last to first: compress, then encrypt. PayloadCodec compressedEncrypted = new ChainCodec(List.of(new AesGcmCodec(SampleConfig.KEY_A_ID, keyA), new ZlibCodec())); @@ -61,6 +63,7 @@ public static DataConverter dataConverter() { SampleConfig.ASYNC_ENCRYPTED_ENDPOINT, asyncEncrypted, SampleConfig.ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT, - asyncCompressedEncrypted)))); + asyncCompressedEncrypted), + new AesGcmCodec(SampleConfig.KEY_E_ID, keyE)))); } } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/AesGcmCodec.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/AesGcmCodec.java index b1893fea..0208849a 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/AesGcmCodec.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/AesGcmCodec.java @@ -21,7 +21,7 @@ import javax.crypto.spec.GCMParameterSpec; import org.apache.commons.lang.StringUtils; -/** Encrypts Nexus payloads with the key assigned to an endpoint. */ +/** Encrypts payloads with a configured key and includes endpoint metadata when available. */ public final class AesGcmCodec implements PayloadCodec { static final String KEY_ID_METADATA_KEY = "encryption-key-id"; @@ -88,7 +88,7 @@ public List decode(@Nonnull List payloads) { .getMetadataOrDefault(EncodingKeys.METADATA_ENCODING_KEY, ByteString.EMPTY) .toStringUtf8(); if (!NexusEncoding.AES_GCM.encodingName().equals(encoding)) { - throw new PayloadCodecException("Expected a Nexus AES-GCM payload"); + throw new PayloadCodecException("Expected an AES-GCM payload"); } String payloadKeyId = payload.getMetadataOrDefault(KEY_ID_METADATA_KEY, ByteString.EMPTY).toStringUtf8(); @@ -122,7 +122,7 @@ private byte[] encrypt(byte[] bytes) { private byte[] decrypt(byte[] encrypted) { if (encrypted.length < NONCE_LENGTH + TAG_LENGTH_BITS / Byte.SIZE) { - throw new PayloadCodecException("Nexus AES-GCM payload is too short"); + throw new PayloadCodecException("AES-GCM payload is too short"); } ByteBuffer buffer = ByteBuffer.wrap(encrypted); byte[] nonce = new byte[NONCE_LENGTH]; diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodec.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodec.java index ad8ec7a9..04b307f3 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodec.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodec.java @@ -10,36 +10,39 @@ import javax.annotation.Nonnull; /** - * Selects a codec when the SDK provides {@link NexusSerializationContext}. The caller uses this for - * both synchronous and asynchronous Nexus requests and results. Non-Nexus workflow payloads pass - * through unchanged. + * Selects an endpoint codec for synchronous and asynchronous Nexus payloads. The caller's own + * workflow payloads use a separate codec. */ public final class NexusCodec implements PayloadCodec { private final Map codecsByEndpoint; + // The caller workflow has no Nexus endpoint, so Key E encrypts its input and final result. + private final PayloadCodec workflowCodec; - public NexusCodec(Map codecsByEndpoint) { + public NexusCodec(Map codecsByEndpoint, PayloadCodec workflowCodec) { this.codecsByEndpoint = Map.copyOf(codecsByEndpoint); + this.workflowCodec = workflowCodec; } @Override @Nonnull public PayloadCodec withContext(@Nonnull SerializationContext context) { - if (!(context instanceof NexusSerializationContext)) { - return this; + if (context instanceof NexusSerializationContext nexusContext) { + return codecFor(nexusContext.getEndpoint()).withContext(context); } - return codecFor(((NexusSerializationContext) context).getEndpoint()).withContext(context); + // Caller Workflow input and result have no Nexus endpoint; use the caller's Key E codec. + return workflowCodec.withContext(context); } @Override @Nonnull public List encode(@Nonnull List payloads) { - return payloads; + return workflowCodec.encode(payloads); } @Override @Nonnull public List decode(@Nonnull List payloads) { - return payloads; + return workflowCodec.decode(payloads); } private PayloadCodec codecFor(String endpoint) { diff --git a/core/src/test/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodecTest.java b/core/src/test/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodecTest.java index d7904b52..df006758 100644 --- a/core/src/test/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodecTest.java +++ b/core/src/test/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodecTest.java @@ -1,6 +1,7 @@ package io.temporal.samples.nexusserializationcontext.codec; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -10,10 +11,10 @@ import io.temporal.common.converter.EncodingKeys; import io.temporal.payload.codec.PayloadCodecException; import io.temporal.payload.context.NexusSerializationContext; -import io.temporal.payload.context.SerializationContext; import io.temporal.payload.context.WorkflowSerializationContext; import io.temporal.samples.nexusserializationcontext.SampleConfig; import io.temporal.samples.nexusserializationcontext.caller.CallerWorker; +import io.temporal.samples.nexusserializationcontext.caller.EndpointResults; import io.temporal.samples.nexusserializationcontext.handler.AsyncHandlerWorker; import io.temporal.samples.nexusserializationcontext.handler.CompressedEncryptedHandlerWorker; import io.temporal.samples.nexusserializationcontext.handler.EncryptedHandlerWorker; @@ -113,16 +114,31 @@ void compressesBeforeEncryptingForTheFirstEndpoint() { } @Test - void leavesNonNexusPayloadsUnchanged() { + void encryptsCallerWorkflowInputAndResultWithKeyE() { DataConverter converter = - CallerWorker.dataConverter().withContext(new SerializationContext() {}); + CallerWorker.dataConverter() + .withContext( + new WorkflowSerializationContext("nexus-serialization-caller", "caller-id")); + EndpointResults results = new EndpointResults("reply A", "reply B", "reply C", "reply D"); - Payload payload = converter.toPayload("hello").orElseThrow(); + Payload input = converter.toPayload("Hello from Nexus").orElseThrow(); + Payload result = converter.toPayload(results).orElseThrow(); assertEquals( - "json/plain", - payload.getMetadataOrThrow(EncodingKeys.METADATA_ENCODING_KEY).toStringUtf8()); - assertEquals("hello", converter.fromPayload(payload, String.class, String.class)); + NexusEncoding.AES_GCM.encodingName(), + input.getMetadataOrThrow(EncodingKeys.METADATA_ENCODING_KEY).toStringUtf8()); + assertEquals( + SampleConfig.KEY_E_ID, + input.getMetadataOrThrow(AesGcmCodec.KEY_ID_METADATA_KEY).toStringUtf8()); + assertEquals( + SampleConfig.KEY_E_ID, + result.getMetadataOrThrow(AesGcmCodec.KEY_ID_METADATA_KEY).toStringUtf8()); + assertFalse(input.getMetadataMap().containsKey(SampleConfig.ENDPOINT_METADATA_KEY)); + assertFalse(input.getData().toStringUtf8().contains("Hello from Nexus")); + assertFalse(result.getData().toStringUtf8().contains("reply A")); + assertEquals("Hello from Nexus", converter.fromPayload(input, String.class, String.class)); + assertEquals( + results, converter.fromPayload(result, EndpointResults.class, EndpointResults.class)); } @Test From 98cfa761b32f4a2af71787be7741a68a70ddf40a Mon Sep 17 00:00:00 2001 From: Joshua Frenchwood Date: Thu, 1 Oct 2026 16:59:35 -0500 Subject: [PATCH 8/8] Updated to use two services and only two endpoints --- .../nexusserializationcontext/README.md | 85 +++----- .../SampleConfig.java | 7 - .../caller/CallerStarter.java | 6 +- .../caller/CallerWorker.java | 15 +- .../caller/CallerWorkflowImpl.java | 32 +-- .../caller/EndpointResults.java | 4 +- .../codec/NexusCodec.java | 4 +- .../codec/PropagatedEndpointCodec.java | 64 ------ ...iceImpl.java => AsyncEchoServiceImpl.java} | 11 +- .../handler/AsyncHandlerWorker.java | 67 ------ .../CompressedEncryptedHandlerWorker.java | 3 +- .../handler/EncryptedHandlerWorker.java | 3 +- .../handler/SyncEchoServiceImpl.java | 14 ++ ...EchoService.java => AsyncEchoService.java} | 10 +- .../service/SyncEchoService.java | 13 ++ .../codec/NexusCodecTest.java | 194 ++++++++---------- 16 files changed, 188 insertions(+), 344 deletions(-) delete mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/PropagatedEndpointCodec.java rename core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/{EchoServiceImpl.java => AsyncEchoServiceImpl.java} (74%) delete mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/AsyncHandlerWorker.java create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/SyncEchoServiceImpl.java rename core/src/main/java/io/temporal/samples/nexusserializationcontext/service/{EchoService.java => AsyncEchoService.java} (53%) create mode 100644 core/src/main/java/io/temporal/samples/nexusserializationcontext/service/SyncEchoService.java diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md b/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md index 0ff9a69a..32fc41ba 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/README.md @@ -1,30 +1,26 @@ # Nexus serialization context -This sample calls two synchronous and two asynchronous Nexus operations through -four endpoints. The synchronous endpoints have separate handler workers. The two -asynchronous endpoints share a handler namespace, task queue, and worker. The caller's -`NexusCodec` uses `NexusSerializationContext` to select the `PayloadCodec` -registered for each endpoint. The sample uses these keys: - -- Key A: compresses with zlib, then encrypts the first synchronous endpoint's payloads. -- Key B: encrypts the second synchronous endpoint's payloads. -- Key C: encrypts one asynchronous endpoint's payloads. -- Key D: compresses with zlib, then encrypts the other asynchronous endpoint's payloads. -- Key E: encrypts the caller workflow's input and final result. - -The caller configures all four endpoint codecs and a separate codec for its own -workflow input and result. The synchronous handler workers each use a fixed key. The -shared asynchronous worker has both Keys C and D and selects a codec by endpoint. +This sample calls a synchronous and an asynchronous Nexus operation through each of +two endpoints. Each endpoint routes to its own handler worker, which registers both +`SyncEchoService` and `AsyncEchoService`. The caller's `NexusCodec` uses +`NexusSerializationContext` to select the `PayloadCodec` registered for each endpoint. +The sample uses these keys: + +- Key A: compresses with zlib, then encrypts synchronous and asynchronous payloads for `nexus-serialization-compressed-encrypted`. +- Key B: encrypts synchronous and asynchronous payloads for `nexus-serialization-encrypted`. +- Key C: encrypts the caller workflow's input and final result. + +The caller configures a codec for each endpoint and a separate codec for its own +workflow input and result. Each handler worker uses the fixed key for its endpoint. The caller schedules all four operations before waiting for their results, so each result must be decoded using the context of its own endpoint. The caller's -`NexusCodec` uses Key E for its own workflow payloads, which have no Nexus endpoint. +`NexusCodec` uses Key C for its own workflow payloads, which have no Nexus endpoint. -For the four Nexus endpoints, the outer encrypted payload stores the endpoint name in -`nexus-endpoint-name` metadata, -alongside `binary/nexus-aes-gcm` and a sample key ID (`key-a`, `key-b`, `key-c`, or `key-d`). A Codec -Server can use the endpoint name to select the matching key and decompression -chain without SDK context. +For both Nexus endpoints, the outer encrypted payload stores the endpoint name in +`nexus-endpoint-name` metadata alongside `binary/nexus-aes-gcm` and a sample key ID +(`key-a` or `key-b`). A Codec Server can use the endpoint name to select the matching +key and decompression chain without SDK context. For the compressed endpoint, the outer payload's decoded metadata looks like: @@ -34,26 +30,27 @@ encryption-key-id: key-a nexus-endpoint-name: nexus-serialization-compressed-encrypted ``` -The asynchronous results have `encryption-key-id: key-c` or `key-d` and their respective +The asynchronous results have `encryption-key-id: key-a` or `key-b` and their respective endpoint names in the outer payload metadata. -The caller workflow's input and final result use `encryption-key-id: key-e`; they do not +The caller workflow's input and final result use `encryption-key-id: key-c`; they do not have endpoint metadata. The starter uses the same converter as the caller worker, so it can decode the final result before printing it. `NexusSerializationContext` works end to end for synchronous Nexus operations. For an asynchronous operation, the handler's final result is serialized as a workflow -result and does not receive `NexusSerializationContext`. This sample's -`NexusEndpointInterceptor` captures the endpoint when the handler starts the backing -workflow. `NexusEndpointContextPropagator` saves it in the workflow headers and restores -it on the workflow thread. The shared worker's `PropagatedEndpointCodec` uses the endpoint -to choose Key C or the Key D compression and encryption chain when encoding the workflow -result. Its `AesGcmCodec` also includes the endpoint name in the encrypted result metadata. -The caller uses `NexusSerializationContext` to decode each asynchronous result. - -This workaround covers asynchronous operations backed by workflows. If the propagated -endpoint is missing, the shared worker's codec rejects the payload instead of leaving -it unencrypted. The shared service implementation exposes both operations on every -worker, although the caller invokes `echoAsync` only through the two async endpoints. +result and does not receive `NexusSerializationContext`. We plan to add +`NexusSerializationContext` support for asynchronous operation results in the Java SDK. +Until then, the sample's `NexusEndpointInterceptor` captures the endpoint when the +handler starts the backing `EchoWorkflow`. `NexusEndpointContextPropagator` saves it in +the workflow headers and restores it on the workflow thread. Each handler's codec uses +its fixed key to encrypt the workflow result and includes the propagated endpoint name +in the outer payload metadata. This propagation is needed for the endpoint metadata; +the handler already knows which key to use. When the result reaches the caller, the SDK +supplies `NexusSerializationContext` to decode it. + +This endpoint propagation covers asynchronous operations backed by workflows. If the +endpoint is not propagated, the result remains encrypted with the handler's fixed key +but lacks the endpoint name in its metadata. The hard-coded keys are only for this local example. For production encryption, use a secure key store, as in the @@ -76,7 +73,6 @@ In another terminal, create the namespaces and endpoints: temporal operator namespace create --namespace nexus-serialization-caller temporal operator namespace create --namespace nexus-serialization-key-a-handler temporal operator namespace create --namespace nexus-serialization-key-b-handler -temporal operator namespace create --namespace nexus-serialization-async-handler temporal operator nexus endpoint create \ --name nexus-serialization-compressed-encrypted \ --target-namespace nexus-serialization-key-a-handler \ @@ -85,14 +81,6 @@ temporal operator nexus endpoint create \ --name nexus-serialization-encrypted \ --target-namespace nexus-serialization-key-b-handler \ --target-task-queue nexus-serialization-key-b-handler -temporal operator nexus endpoint create \ - --name nexus-serialization-async-encrypted \ - --target-namespace nexus-serialization-async-handler \ - --target-task-queue nexus-serialization-async-handler -temporal operator nexus endpoint create \ - --name nexus-serialization-async-compressed-encrypted \ - --target-namespace nexus-serialization-async-handler \ - --target-task-queue nexus-serialization-async-handler ``` Run each of the following in its own terminal from the repository root: @@ -107,11 +95,6 @@ Run each of the following in its own terminal from the repository root: --args="-namespace nexus-serialization-key-b-handler" ``` -```bash -./gradlew -q :core:execute -PmainClass=io.temporal.samples.nexusserializationcontext.handler.AsyncHandlerWorker \ - --args="-namespace nexus-serialization-async-handler" -``` - ```bash ./gradlew -q :core:execute -PmainClass=io.temporal.samples.nexusserializationcontext.caller.CallerWorker \ --args="-namespace nexus-serialization-caller" @@ -127,6 +110,6 @@ The starter will print: ```text Compressed and encrypted endpoint sync result: Hello from Nexus Encrypted endpoint sync result: Hello from Nexus -Async encrypted endpoint result: Hello from Nexus -Async compressed and encrypted endpoint result: Hello from Nexus +Compressed and encrypted endpoint async result: Hello from Nexus +Encrypted endpoint async result: Hello from Nexus ``` diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java index 8ab49d9a..1ba965c4 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/SampleConfig.java @@ -4,22 +4,15 @@ public final class SampleConfig { public static final String COMPRESSED_ENCRYPTED_ENDPOINT = "nexus-serialization-compressed-encrypted"; public static final String ENCRYPTED_ENDPOINT = "nexus-serialization-encrypted"; - public static final String ASYNC_ENCRYPTED_ENDPOINT = "nexus-serialization-async-encrypted"; - public static final String ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT = - "nexus-serialization-async-compressed-encrypted"; public static final String ENDPOINT_METADATA_KEY = "nexus-endpoint-name"; public static final String KEY_A_ID = "key-a"; public static final String KEY_B_ID = "key-b"; public static final String KEY_C_ID = "key-c"; - public static final String KEY_D_ID = "key-d"; - public static final String KEY_E_ID = "key-e"; // Hard-coded keys are only for this local sample. public static final String KEY_A_VALUE = "sample-key-A-123"; public static final String KEY_B_VALUE = "sample-key-B-123"; public static final String KEY_C_VALUE = "sample-key-C-123"; - public static final String KEY_D_VALUE = "sample-key-D-123"; - public static final String KEY_E_VALUE = "sample-key-E-123"; private SampleConfig() {} } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerStarter.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerStarter.java index 3611c146..7defd522 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerStarter.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerStarter.java @@ -21,9 +21,9 @@ public static void main(String[] args) { "Compressed and encrypted endpoint sync result: " + results.compressedEncryptedSyncResult()); System.out.println("Encrypted endpoint sync result: " + results.encryptedSyncResult()); - System.out.println("Async encrypted endpoint result: " + results.asyncEncryptedResult()); System.out.println( - "Async compressed and encrypted endpoint result: " - + results.asyncCompressedEncryptedResult()); + "Compressed and encrypted endpoint async result: " + + results.compressedEncryptedAsyncResult()); + System.out.println("Encrypted endpoint async result: " + results.encryptedAsyncResult()); } } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorker.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorker.java index a24fdc3a..3c993809 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorker.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorker.java @@ -40,17 +40,10 @@ public static DataConverter dataConverter() { new SecretKeySpec(SampleConfig.KEY_B_VALUE.getBytes(StandardCharsets.UTF_8), "AES"); SecretKey keyC = new SecretKeySpec(SampleConfig.KEY_C_VALUE.getBytes(StandardCharsets.UTF_8), "AES"); - SecretKey keyD = - new SecretKeySpec(SampleConfig.KEY_D_VALUE.getBytes(StandardCharsets.UTF_8), "AES"); - SecretKey keyE = - new SecretKeySpec(SampleConfig.KEY_E_VALUE.getBytes(StandardCharsets.UTF_8), "AES"); // ChainCodec encodes last to first: compress, then encrypt. PayloadCodec compressedEncrypted = new ChainCodec(List.of(new AesGcmCodec(SampleConfig.KEY_A_ID, keyA), new ZlibCodec())); PayloadCodec encrypted = new AesGcmCodec(SampleConfig.KEY_B_ID, keyB); - PayloadCodec asyncEncrypted = new AesGcmCodec(SampleConfig.KEY_C_ID, keyC); - PayloadCodec asyncCompressedEncrypted = - new ChainCodec(List.of(new AesGcmCodec(SampleConfig.KEY_D_ID, keyD), new ZlibCodec())); return new CodecDataConverter( DefaultDataConverter.newDefaultInstance(), List.of( @@ -59,11 +52,7 @@ public static DataConverter dataConverter() { SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT, compressedEncrypted, SampleConfig.ENCRYPTED_ENDPOINT, - encrypted, - SampleConfig.ASYNC_ENCRYPTED_ENDPOINT, - asyncEncrypted, - SampleConfig.ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT, - asyncCompressedEncrypted), - new AesGcmCodec(SampleConfig.KEY_E_ID, keyE)))); + encrypted), + new AesGcmCodec(SampleConfig.KEY_C_ID, keyC)))); } } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflowImpl.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflowImpl.java index f56eb5b0..9e0505b0 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflowImpl.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/CallerWorkflowImpl.java @@ -1,7 +1,8 @@ package io.temporal.samples.nexusserializationcontext.caller; import io.temporal.samples.nexusserializationcontext.SampleConfig; -import io.temporal.samples.nexusserializationcontext.service.EchoService; +import io.temporal.samples.nexusserializationcontext.service.AsyncEchoService; +import io.temporal.samples.nexusserializationcontext.service.SyncEchoService; import io.temporal.workflow.NexusOperationHandle; import io.temporal.workflow.NexusOperationOptions; import io.temporal.workflow.NexusServiceOptions; @@ -11,31 +12,34 @@ public class CallerWorkflowImpl implements CallerWorkflow { @Override public EndpointResults echoThroughEndpoints(String message) { - EchoService compressedEncryptedService = serviceFor(SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT); - EchoService encryptedService = serviceFor(SampleConfig.ENCRYPTED_ENDPOINT); - EchoService asyncEncryptedService = serviceFor(SampleConfig.ASYNC_ENCRYPTED_ENDPOINT); - EchoService asyncCompressedEncryptedService = - serviceFor(SampleConfig.ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT); + SyncEchoService compressedEncryptedService = + serviceFor(SyncEchoService.class, SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT); + SyncEchoService encryptedService = + serviceFor(SyncEchoService.class, SampleConfig.ENCRYPTED_ENDPOINT); + AsyncEchoService compressedEncryptedAsyncService = + serviceFor(AsyncEchoService.class, SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT); + AsyncEchoService encryptedAsyncService = + serviceFor(AsyncEchoService.class, SampleConfig.ENCRYPTED_ENDPOINT); // Start all operations before awaiting results. Each result keeps its endpoint context. NexusOperationHandle compressedEncryptedSync = Workflow.startNexusOperation(compressedEncryptedService::echo, message); NexusOperationHandle encryptedSync = Workflow.startNexusOperation(encryptedService::echo, message); - NexusOperationHandle asyncEncrypted = - Workflow.startNexusOperation(asyncEncryptedService::echoAsync, message); - NexusOperationHandle asyncCompressedEncrypted = - Workflow.startNexusOperation(asyncCompressedEncryptedService::echoAsync, message); + NexusOperationHandle compressedEncryptedAsync = + Workflow.startNexusOperation(compressedEncryptedAsyncService::echoAsync, message); + NexusOperationHandle encryptedAsync = + Workflow.startNexusOperation(encryptedAsyncService::echoAsync, message); return new EndpointResults( compressedEncryptedSync.getResult().get(), encryptedSync.getResult().get(), - asyncEncrypted.getResult().get(), - asyncCompressedEncrypted.getResult().get()); + compressedEncryptedAsync.getResult().get(), + encryptedAsync.getResult().get()); } - private static EchoService serviceFor(String endpoint) { + private static T serviceFor(Class serviceClass, String endpoint) { return Workflow.newNexusServiceStub( - EchoService.class, + serviceClass, NexusServiceOptions.newBuilder() .setEndpoint(endpoint) .setOperationOptions( diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/EndpointResults.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/EndpointResults.java index ed623d49..741f9286 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/EndpointResults.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/caller/EndpointResults.java @@ -3,5 +3,5 @@ public record EndpointResults( String compressedEncryptedSyncResult, String encryptedSyncResult, - String asyncEncryptedResult, - String asyncCompressedEncryptedResult) {} + String compressedEncryptedAsyncResult, + String encryptedAsyncResult) {} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodec.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodec.java index 04b307f3..3c6284ea 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodec.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodec.java @@ -15,7 +15,7 @@ */ public final class NexusCodec implements PayloadCodec { private final Map codecsByEndpoint; - // The caller workflow has no Nexus endpoint, so Key E encrypts its input and final result. + // The caller workflow has no Nexus endpoint, so Key C encrypts its input and final result. private final PayloadCodec workflowCodec; public NexusCodec(Map codecsByEndpoint, PayloadCodec workflowCodec) { @@ -29,7 +29,7 @@ public PayloadCodec withContext(@Nonnull SerializationContext context) { if (context instanceof NexusSerializationContext nexusContext) { return codecFor(nexusContext.getEndpoint()).withContext(context); } - // Caller Workflow input and result have no Nexus endpoint; use the caller's Key E codec. + // Caller workflow input and result have no Nexus endpoint; use the caller's Key C codec. return workflowCodec.withContext(context); } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/PropagatedEndpointCodec.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/PropagatedEndpointCodec.java deleted file mode 100644 index c6567715..00000000 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/codec/PropagatedEndpointCodec.java +++ /dev/null @@ -1,64 +0,0 @@ -package io.temporal.samples.nexusserializationcontext.codec; - -import io.temporal.api.common.v1.Payload; -import io.temporal.payload.codec.PayloadCodec; -import io.temporal.payload.codec.PayloadCodecException; -import io.temporal.payload.context.NexusSerializationContext; -import io.temporal.payload.context.SerializationContext; -import io.temporal.samples.nexusserializationcontext.propagation.NexusEndpointContextPropagator; -import java.util.List; -import java.util.Map; -import javax.annotation.Nonnull; -import org.apache.commons.lang.StringUtils; - -/** - * Selects a codec on the shared handler for workflow-backed asynchronous operations. The incoming - * Nexus request has {@link NexusSerializationContext}; the backing workflow's payloads and final - * result use the endpoint carried by the context propagator instead. - */ -public final class PropagatedEndpointCodec implements PayloadCodec { - private final Map codecsByEndpoint; - - public PropagatedEndpointCodec(Map codecsByEndpoint) { - this.codecsByEndpoint = Map.copyOf(codecsByEndpoint); - } - - @Override - @Nonnull - public PayloadCodec withContext(@Nonnull SerializationContext context) { - // The incoming Nexus request has this context; the backing workflow's result does not. - if (context instanceof NexusSerializationContext nexusContext) { - return codecFor(nexusContext.getEndpoint()).withContext(context); - } - return this; - } - - @Override - @Nonnull - public List encode(@Nonnull List payloads) { - return codecForCurrentEndpoint().encode(payloads); - } - - @Override - @Nonnull - public List decode(@Nonnull List payloads) { - return codecForCurrentEndpoint().decode(payloads); - } - - private PayloadCodec codecForCurrentEndpoint() { - // For backing workflow payloads, choose the codec from the propagated endpoint. - String endpoint = NexusEndpointContextPropagator.currentEndpoint(); - if (StringUtils.isBlank(endpoint)) { - throw new PayloadCodecException("Missing propagated Nexus endpoint"); - } - return codecFor(endpoint); - } - - private PayloadCodec codecFor(String endpoint) { - PayloadCodec codec = codecsByEndpoint.get(endpoint); - if (codec == null) { - throw new PayloadCodecException("Unknown Nexus endpoint: " + endpoint); - } - return codec; - } -} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EchoServiceImpl.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/AsyncEchoServiceImpl.java similarity index 74% rename from core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EchoServiceImpl.java rename to core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/AsyncEchoServiceImpl.java index bd441bf0..7d85ce9c 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EchoServiceImpl.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/AsyncEchoServiceImpl.java @@ -6,15 +6,10 @@ import io.temporal.client.WorkflowOptions; import io.temporal.nexus.Nexus; import io.temporal.nexus.WorkflowRunOperation; -import io.temporal.samples.nexusserializationcontext.service.EchoService; - -@ServiceImpl(service = EchoService.class) -public class EchoServiceImpl { - @OperationImpl - public OperationHandler echo() { - return OperationHandler.sync((ctx, details, message) -> message); - } +import io.temporal.samples.nexusserializationcontext.service.AsyncEchoService; +@ServiceImpl(service = AsyncEchoService.class) +public class AsyncEchoServiceImpl { @OperationImpl public OperationHandler echoAsync() { return WorkflowRunOperation.fromWorkflowMethod( diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/AsyncHandlerWorker.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/AsyncHandlerWorker.java deleted file mode 100644 index be41708d..00000000 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/AsyncHandlerWorker.java +++ /dev/null @@ -1,67 +0,0 @@ -package io.temporal.samples.nexusserializationcontext.handler; - -import io.temporal.client.WorkflowClient; -import io.temporal.client.WorkflowClientOptions; -import io.temporal.common.converter.CodecDataConverter; -import io.temporal.common.converter.DataConverter; -import io.temporal.common.converter.DefaultDataConverter; -import io.temporal.payload.codec.ChainCodec; -import io.temporal.payload.codec.PayloadCodec; -import io.temporal.samples.nexus.options.ClientOptions; -import io.temporal.samples.nexusserializationcontext.SampleConfig; -import io.temporal.samples.nexusserializationcontext.codec.AesGcmCodec; -import io.temporal.samples.nexusserializationcontext.codec.PropagatedEndpointCodec; -import io.temporal.samples.nexusserializationcontext.codec.ZlibCodec; -import io.temporal.samples.nexusserializationcontext.propagation.NexusEndpointContextPropagator; -import io.temporal.samples.nexusserializationcontext.propagation.NexusEndpointInterceptor; -import io.temporal.worker.Worker; -import io.temporal.worker.WorkerFactory; -import io.temporal.worker.WorkerFactoryOptions; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Map; -import javax.crypto.SecretKey; -import javax.crypto.spec.SecretKeySpec; - -public class AsyncHandlerWorker { - private static final String TASK_QUEUE = "nexus-serialization-async-handler"; - - public static void main(String[] args) { - WorkflowClient client = - ClientOptions.getWorkflowClient( - args, - WorkflowClientOptions.newBuilder() - .setDataConverter(dataConverter()) - .setContextPropagators(List.of(new NexusEndpointContextPropagator()))); - WorkerFactory factory = - WorkerFactory.newInstance( - client, - WorkerFactoryOptions.newBuilder() - .setWorkerInterceptors(new NexusEndpointInterceptor()) - .build()); - Worker worker = factory.newWorker(TASK_QUEUE); - worker.registerWorkflowImplementationTypes(EchoWorkflowImpl.class); - worker.registerNexusServiceImplementation(new EchoServiceImpl()); - factory.start(); - } - - public static DataConverter dataConverter() { - SecretKey keyC = - new SecretKeySpec(SampleConfig.KEY_C_VALUE.getBytes(StandardCharsets.UTF_8), "AES"); - SecretKey keyD = - new SecretKeySpec(SampleConfig.KEY_D_VALUE.getBytes(StandardCharsets.UTF_8), "AES"); - PayloadCodec encrypted = new AesGcmCodec(SampleConfig.KEY_C_ID, keyC); - // ChainCodec encodes last to first: compress, then encrypt. - PayloadCodec compressedEncrypted = - new ChainCodec(List.of(new AesGcmCodec(SampleConfig.KEY_D_ID, keyD), new ZlibCodec())); - return new CodecDataConverter( - DefaultDataConverter.newDefaultInstance(), - List.of( - new PropagatedEndpointCodec( - Map.of( - SampleConfig.ASYNC_ENCRYPTED_ENDPOINT, - encrypted, - SampleConfig.ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT, - compressedEncrypted)))); - } -} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/CompressedEncryptedHandlerWorker.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/CompressedEncryptedHandlerWorker.java index 025ec514..9640b0f2 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/CompressedEncryptedHandlerWorker.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/CompressedEncryptedHandlerWorker.java @@ -38,7 +38,8 @@ public static void main(String[] args) { .build()); Worker worker = factory.newWorker(TASK_QUEUE); worker.registerWorkflowImplementationTypes(EchoWorkflowImpl.class); - worker.registerNexusServiceImplementation(new EchoServiceImpl()); + worker.registerNexusServiceImplementation(new SyncEchoServiceImpl()); + worker.registerNexusServiceImplementation(new AsyncEchoServiceImpl()); factory.start(); } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EncryptedHandlerWorker.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EncryptedHandlerWorker.java index 9fb4bb4c..85525aac 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EncryptedHandlerWorker.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/EncryptedHandlerWorker.java @@ -36,7 +36,8 @@ public static void main(String[] args) { .build()); Worker worker = factory.newWorker(TASK_QUEUE); worker.registerWorkflowImplementationTypes(EchoWorkflowImpl.class); - worker.registerNexusServiceImplementation(new EchoServiceImpl()); + worker.registerNexusServiceImplementation(new SyncEchoServiceImpl()); + worker.registerNexusServiceImplementation(new AsyncEchoServiceImpl()); factory.start(); } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/SyncEchoServiceImpl.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/SyncEchoServiceImpl.java new file mode 100644 index 00000000..9cb1da93 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/handler/SyncEchoServiceImpl.java @@ -0,0 +1,14 @@ +package io.temporal.samples.nexusserializationcontext.handler; + +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.samples.nexusserializationcontext.service.SyncEchoService; + +@ServiceImpl(service = SyncEchoService.class) +public class SyncEchoServiceImpl { + @OperationImpl + public OperationHandler echo() { + return OperationHandler.sync((ctx, details, message) -> message); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/service/EchoService.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/service/AsyncEchoService.java similarity index 53% rename from core/src/main/java/io/temporal/samples/nexusserializationcontext/service/EchoService.java rename to core/src/main/java/io/temporal/samples/nexusserializationcontext/service/AsyncEchoService.java index 738b734c..b9bee42d 100644 --- a/core/src/main/java/io/temporal/samples/nexusserializationcontext/service/EchoService.java +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/service/AsyncEchoService.java @@ -3,15 +3,11 @@ import io.nexusrpc.Operation; import io.nexusrpc.Service; -@Service(name = EchoService.SERVICE_NAME) -public interface EchoService { - String SERVICE_NAME = "EchoService"; - String ECHO_OPERATION_NAME = "echo"; +@Service(name = AsyncEchoService.SERVICE_NAME) +public interface AsyncEchoService { + String SERVICE_NAME = "AsyncEchoService"; String ECHO_ASYNC_OPERATION_NAME = "echoAsync"; - @Operation(name = ECHO_OPERATION_NAME) - String echo(String message); - @Operation(name = ECHO_ASYNC_OPERATION_NAME) String echoAsync(String message); } diff --git a/core/src/main/java/io/temporal/samples/nexusserializationcontext/service/SyncEchoService.java b/core/src/main/java/io/temporal/samples/nexusserializationcontext/service/SyncEchoService.java new file mode 100644 index 00000000..d3b94727 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexusserializationcontext/service/SyncEchoService.java @@ -0,0 +1,13 @@ +package io.temporal.samples.nexusserializationcontext.service; + +import io.nexusrpc.Operation; +import io.nexusrpc.Service; + +@Service(name = SyncEchoService.SERVICE_NAME) +public interface SyncEchoService { + String SERVICE_NAME = "SyncEchoService"; + String ECHO_OPERATION_NAME = "echo"; + + @Operation(name = ECHO_OPERATION_NAME) + String echo(String message); +} diff --git a/core/src/test/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodecTest.java b/core/src/test/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodecTest.java index df006758..b5329952 100644 --- a/core/src/test/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodecTest.java +++ b/core/src/test/java/io/temporal/samples/nexusserializationcontext/codec/NexusCodecTest.java @@ -15,106 +15,72 @@ import io.temporal.samples.nexusserializationcontext.SampleConfig; import io.temporal.samples.nexusserializationcontext.caller.CallerWorker; import io.temporal.samples.nexusserializationcontext.caller.EndpointResults; -import io.temporal.samples.nexusserializationcontext.handler.AsyncHandlerWorker; import io.temporal.samples.nexusserializationcontext.handler.CompressedEncryptedHandlerWorker; import io.temporal.samples.nexusserializationcontext.handler.EncryptedHandlerWorker; import io.temporal.samples.nexusserializationcontext.propagation.NexusEndpointContextPropagator; -import io.temporal.samples.nexusserializationcontext.service.EchoService; +import io.temporal.samples.nexusserializationcontext.service.AsyncEchoService; +import io.temporal.samples.nexusserializationcontext.service.SyncEchoService; import org.junit.jupiter.api.Test; class NexusCodecTest { @Test - void encryptsWithTheKeyForEachEndpoint() { - DataConverter keyAConverter = converterFor(SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT); - DataConverter keyBConverter = converterFor(SampleConfig.ENCRYPTED_ENDPOINT); - DataConverter keyCConverter = converterFor(SampleConfig.ASYNC_ENCRYPTED_ENDPOINT); - DataConverter keyDConverter = converterFor(SampleConfig.ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT); + void encryptsSyncAndAsyncOperationsWithTheKeyForTheirEndpoint() { + DataConverter syncA = converterFor(SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT); + DataConverter asyncA = converterFor(SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT, true); + DataConverter syncB = converterFor(SampleConfig.ENCRYPTED_ENDPOINT); + DataConverter asyncB = converterFor(SampleConfig.ENCRYPTED_ENDPOINT, true); - Payload keyAPayload = keyAConverter.toPayload("hello").orElseThrow(); - Payload keyBPayload = keyBConverter.toPayload("hello").orElseThrow(); - Payload keyCPayload = keyCConverter.toPayload("hello").orElseThrow(); - Payload keyDPayload = keyDConverter.toPayload("hello").orElseThrow(); + Payload syncAPayload = syncA.toPayload("hello").orElseThrow(); + Payload asyncAPayload = asyncA.toPayload("hello").orElseThrow(); + Payload syncBPayload = syncB.toPayload("hello").orElseThrow(); + Payload asyncBPayload = asyncB.toPayload("hello").orElseThrow(); - assertEquals( - NexusEncoding.AES_GCM.encodingName(), - keyAPayload.getMetadataOrThrow(EncodingKeys.METADATA_ENCODING_KEY).toStringUtf8()); - assertEquals( - NexusEncoding.AES_GCM.encodingName(), - keyBPayload.getMetadataOrThrow(EncodingKeys.METADATA_ENCODING_KEY).toStringUtf8()); - assertEquals( - NexusEncoding.AES_GCM.encodingName(), - keyCPayload.getMetadataOrThrow(EncodingKeys.METADATA_ENCODING_KEY).toStringUtf8()); - assertEquals( - NexusEncoding.AES_GCM.encodingName(), - keyDPayload.getMetadataOrThrow(EncodingKeys.METADATA_ENCODING_KEY).toStringUtf8()); - assertEquals( - SampleConfig.KEY_A_ID, - keyAPayload.getMetadataOrThrow(AesGcmCodec.KEY_ID_METADATA_KEY).toStringUtf8()); - assertEquals( - SampleConfig.KEY_B_ID, - keyBPayload.getMetadataOrThrow(AesGcmCodec.KEY_ID_METADATA_KEY).toStringUtf8()); - assertEquals( - SampleConfig.KEY_C_ID, - keyCPayload.getMetadataOrThrow(AesGcmCodec.KEY_ID_METADATA_KEY).toStringUtf8()); - assertEquals( - SampleConfig.KEY_D_ID, - keyDPayload.getMetadataOrThrow(AesGcmCodec.KEY_ID_METADATA_KEY).toStringUtf8()); - assertEquals( - SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT, - keyAPayload.getMetadataOrThrow(SampleConfig.ENDPOINT_METADATA_KEY).toStringUtf8()); - assertEquals( - SampleConfig.ENCRYPTED_ENDPOINT, - keyBPayload.getMetadataOrThrow(SampleConfig.ENDPOINT_METADATA_KEY).toStringUtf8()); - assertEquals( - SampleConfig.ASYNC_ENCRYPTED_ENDPOINT, - keyCPayload.getMetadataOrThrow(SampleConfig.ENDPOINT_METADATA_KEY).toStringUtf8()); - assertEquals( - SampleConfig.ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT, - keyDPayload.getMetadataOrThrow(SampleConfig.ENDPOINT_METADATA_KEY).toStringUtf8()); - assertEquals("hello", keyAConverter.fromPayload(keyAPayload, String.class, String.class)); - assertEquals("hello", keyBConverter.fromPayload(keyBPayload, String.class, String.class)); - assertEquals("hello", keyCConverter.fromPayload(keyCPayload, String.class, String.class)); - assertEquals("hello", keyDConverter.fromPayload(keyDPayload, String.class, String.class)); - assertThrows( - PayloadCodecException.class, - () -> keyAConverter.fromPayload(keyBPayload, String.class, String.class)); + assertEncryptedFor( + syncAPayload, SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT, SampleConfig.KEY_A_ID); + assertEncryptedFor( + asyncAPayload, SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT, SampleConfig.KEY_A_ID); + assertEncryptedFor(syncBPayload, SampleConfig.ENCRYPTED_ENDPOINT, SampleConfig.KEY_B_ID); + assertEncryptedFor(asyncBPayload, SampleConfig.ENCRYPTED_ENDPOINT, SampleConfig.KEY_B_ID); + assertEquals("hello", syncA.fromPayload(syncAPayload, String.class, String.class)); + assertEquals("hello", asyncA.fromPayload(asyncAPayload, String.class, String.class)); + assertEquals("hello", syncB.fromPayload(syncBPayload, String.class, String.class)); + assertEquals("hello", asyncB.fromPayload(asyncBPayload, String.class, String.class)); assertThrows( PayloadCodecException.class, - () -> keyBConverter.fromPayload(keyCPayload, String.class, String.class)); + () -> syncA.fromPayload(syncBPayload, String.class, String.class)); assertThrows( PayloadCodecException.class, - () -> keyCConverter.fromPayload(keyDPayload, String.class, String.class)); + () -> asyncB.fromPayload(asyncAPayload, String.class, String.class)); Payload keyAPayloadMarkedAsB = - keyAPayload.toBuilder() + syncAPayload.toBuilder() .putMetadata( AesGcmCodec.KEY_ID_METADATA_KEY, ByteString.copyFromUtf8(SampleConfig.KEY_B_ID)) .build(); assertThrows( PayloadCodecException.class, - () -> keyBConverter.fromPayload(keyAPayloadMarkedAsB, String.class, String.class)); + () -> syncB.fromPayload(keyAPayloadMarkedAsB, String.class, String.class)); } @Test - void compressesBeforeEncryptingForTheFirstEndpoint() { + void compressesBeforeEncryptingForBothOperationsOnTheCompressedEndpoint() { String message = "repeat me ".repeat(100); - Payload compressed = + Payload syncCompressed = converterFor(SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT).toPayload(message).orElseThrow(); - Payload encryptedOnly = + Payload syncEncryptedOnly = converterFor(SampleConfig.ENCRYPTED_ENDPOINT).toPayload(message).orElseThrow(); - - assertTrue(compressed.getData().size() < encryptedOnly.getData().size()); - Payload asyncCompressed = - converterFor(SampleConfig.ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT) + converterFor(SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT, true) .toPayload(message) .orElseThrow(); Payload asyncEncrypted = - converterFor(SampleConfig.ASYNC_ENCRYPTED_ENDPOINT).toPayload(message).orElseThrow(); + converterFor(SampleConfig.ENCRYPTED_ENDPOINT, true).toPayload(message).orElseThrow(); + + assertTrue(syncCompressed.getData().size() < syncEncryptedOnly.getData().size()); assertTrue(asyncCompressed.getData().size() < asyncEncrypted.getData().size()); } @Test - void encryptsCallerWorkflowInputAndResultWithKeyE() { + void encryptsCallerWorkflowInputAndResultWithKeyC() { DataConverter converter = CallerWorker.dataConverter() .withContext( @@ -128,10 +94,10 @@ void encryptsCallerWorkflowInputAndResultWithKeyE() { NexusEncoding.AES_GCM.encodingName(), input.getMetadataOrThrow(EncodingKeys.METADATA_ENCODING_KEY).toStringUtf8()); assertEquals( - SampleConfig.KEY_E_ID, + SampleConfig.KEY_C_ID, input.getMetadataOrThrow(AesGcmCodec.KEY_ID_METADATA_KEY).toStringUtf8()); assertEquals( - SampleConfig.KEY_E_ID, + SampleConfig.KEY_C_ID, result.getMetadataOrThrow(AesGcmCodec.KEY_ID_METADATA_KEY).toStringUtf8()); assertFalse(input.getMetadataMap().containsKey(SampleConfig.ENDPOINT_METADATA_KEY)); assertFalse(input.getData().toStringUtf8().contains("Hello from Nexus")); @@ -147,7 +113,7 @@ void rejectsUnknownEndpoints() { CallerWorker.dataConverter() .withContext( new NexusSerializationContext( - "unknown", EchoService.SERVICE_NAME, EchoService.ECHO_OPERATION_NAME)); + "unknown", SyncEchoService.SERVICE_NAME, SyncEchoService.ECHO_OPERATION_NAME)); assertThrows(PayloadCodecException.class, () -> converter.toPayload("hello")); } @@ -195,47 +161,49 @@ void synchronousHandlersUseTheirOwnCodecWithoutEndpointContext() { } @Test - void sharedAsyncHandlerDecodesRequestsUsingNexusContext() { - DataConverter handlerC = - AsyncHandlerWorker.dataConverter() - .withContext(contextFor(SampleConfig.ASYNC_ENCRYPTED_ENDPOINT)); - DataConverter handlerD = - AsyncHandlerWorker.dataConverter() - .withContext(contextFor(SampleConfig.ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT)); - Payload requestC = - converterFor(SampleConfig.ASYNC_ENCRYPTED_ENDPOINT).toPayload("hello").orElseThrow(); - Payload requestD = - converterFor(SampleConfig.ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT) + void handlersDecodeAsyncRequestsWithTheirEndpointKey() { + DataConverter handlerA = + CompressedEncryptedHandlerWorker.dataConverter() + .withContext(contextFor(SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT, true)); + DataConverter handlerB = + EncryptedHandlerWorker.dataConverter() + .withContext(contextFor(SampleConfig.ENCRYPTED_ENDPOINT, true)); + Payload requestA = + converterFor(SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT, true) .toPayload("hello") .orElseThrow(); + Payload requestB = + converterFor(SampleConfig.ENCRYPTED_ENDPOINT, true).toPayload("hello").orElseThrow(); - assertEquals("hello", handlerC.fromPayload(requestC, String.class, String.class)); - assertEquals("hello", handlerD.fromPayload(requestD, String.class, String.class)); + assertEquals("hello", handlerA.fromPayload(requestA, String.class, String.class)); + assertEquals("hello", handlerB.fromPayload(requestB, String.class, String.class)); assertThrows( PayloadCodecException.class, - () -> handlerC.fromPayload(requestD, String.class, String.class)); + () -> handlerA.fromPayload(requestB, String.class, String.class)); } @Test - void propagatedEndpointSelectsAsyncWorkflowResultCodec() { + void propagatedEndpointAppearsOnEncryptedAsyncWorkflowResult() { NexusEndpointContextPropagator propagator = new NexusEndpointContextPropagator(); try { propagator.setCurrentContext(null); - DataConverter handler = - AsyncHandlerWorker.dataConverter() - .withContext(new WorkflowSerializationContext("handler-namespace", "echo-workflow")); - assertThrows(PayloadCodecException.class, () -> handler.toPayload("reply")); + DataConverter handlerA = + CompressedEncryptedHandlerWorker.dataConverter() + .withContext(new WorkflowSerializationContext("handler-a", "echo-workflow")); + DataConverter handlerB = + EncryptedHandlerWorker.dataConverter() + .withContext(new WorkflowSerializationContext("handler-b", "echo-workflow")); - Payload resultC = - asyncResultFor( - propagator, handler, SampleConfig.ASYNC_ENCRYPTED_ENDPOINT, SampleConfig.KEY_C_ID); - Payload resultD = + Payload resultA = asyncResultFor( propagator, - handler, - SampleConfig.ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT, - SampleConfig.KEY_D_ID); - assertTrue(resultD.getData().size() < resultC.getData().size()); + handlerA, + SampleConfig.COMPRESSED_ENCRYPTED_ENDPOINT, + SampleConfig.KEY_A_ID); + Payload resultB = + asyncResultFor( + propagator, handlerB, SampleConfig.ENCRYPTED_ENDPOINT, SampleConfig.KEY_B_ID); + assertTrue(resultA.getData().size() < resultB.getData().size()); } finally { propagator.setCurrentContext(null); } @@ -256,7 +224,8 @@ private static Payload asyncResultFor( assertEquals( endpoint, result.getMetadataOrThrow(SampleConfig.ENDPOINT_METADATA_KEY).toStringUtf8()); assertEquals(keyId, result.getMetadataOrThrow(AesGcmCodec.KEY_ID_METADATA_KEY).toStringUtf8()); - assertEquals(message, converterFor(endpoint).fromPayload(result, String.class, String.class)); + assertEquals( + message, converterFor(endpoint, true).fromPayload(result, String.class, String.class)); assertEquals(message, handler.fromPayload(result, String.class, String.class)); propagator.setCurrentContext(null); return result; @@ -266,12 +235,29 @@ private static DataConverter converterFor(String endpoint) { return CallerWorker.dataConverter().withContext(contextFor(endpoint)); } + private static DataConverter converterFor(String endpoint, boolean async) { + return CallerWorker.dataConverter().withContext(contextFor(endpoint, async)); + } + private static NexusSerializationContext contextFor(String endpoint) { - String operation = - SampleConfig.ASYNC_ENCRYPTED_ENDPOINT.equals(endpoint) - || SampleConfig.ASYNC_COMPRESSED_ENCRYPTED_ENDPOINT.equals(endpoint) - ? EchoService.ECHO_ASYNC_OPERATION_NAME - : EchoService.ECHO_OPERATION_NAME; - return new NexusSerializationContext(endpoint, EchoService.SERVICE_NAME, operation); + return contextFor(endpoint, false); + } + + private static NexusSerializationContext contextFor(String endpoint, boolean async) { + if (async) { + return new NexusSerializationContext( + endpoint, AsyncEchoService.SERVICE_NAME, AsyncEchoService.ECHO_ASYNC_OPERATION_NAME); + } + return new NexusSerializationContext( + endpoint, SyncEchoService.SERVICE_NAME, SyncEchoService.ECHO_OPERATION_NAME); + } + + private static void assertEncryptedFor(Payload payload, String endpoint, String keyId) { + assertEquals( + NexusEncoding.AES_GCM.encodingName(), + payload.getMetadataOrThrow(EncodingKeys.METADATA_ENCODING_KEY).toStringUtf8()); + assertEquals(keyId, payload.getMetadataOrThrow(AesGcmCodec.KEY_ID_METADATA_KEY).toStringUtf8()); + assertEquals( + endpoint, payload.getMetadataOrThrow(SampleConfig.ENDPOINT_METADATA_KEY).toStringUtf8()); } }