diff --git a/temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java b/temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java new file mode 100644 index 0000000000..459c2a4132 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java @@ -0,0 +1,23 @@ +package io.temporal.common.converter; + +import io.temporal.failure.ApplicationFailure; + +/** Factory for failures raised when converting a value that violates its payload schema. */ +public final class PayloadValidationException { + private static final String MESSAGE = "Payload validation failed"; + private static final String TYPE = "PayloadValidationError"; + + private PayloadValidationException() {} + + /** + * Creates a non-retryable failure containing payload validation details. + * + *

The details are stored as a single value and serialized by the configured {@link + * DataConverter}. + * + * @param details payload validation details + */ + public static ApplicationFailure newPayloadValidationException(Object details) { + return ApplicationFailure.newNonRetryableFailure(MESSAGE, TYPE, details); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java b/temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java new file mode 100644 index 0000000000..bca471d72b --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java @@ -0,0 +1,39 @@ +package io.temporal.common.converter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.google.common.reflect.TypeToken; +import io.temporal.api.failure.v1.Failure; +import io.temporal.failure.ApplicationFailure; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.Test; + +public class PayloadValidationExceptionTest { + @Test + public void + newPayloadValidationExceptionReturnsNonRetryableApplicationFailureWithEncodedDetails() { + List> details = + Collections.singletonList(Collections.singletonMap("path", "$.customer.contact.email")); + + ApplicationFailure applicationFailure = + PayloadValidationException.newPayloadValidationException(details); + + assertEquals("PayloadValidationError", applicationFailure.getType()); + assertTrue(applicationFailure.isNonRetryable()); + assertEquals("Payload validation failed", applicationFailure.getOriginalMessage()); + assertEquals(1, applicationFailure.getDetails().getSize()); + + DataConverter dataConverter = DefaultDataConverter.STANDARD_INSTANCE; + Failure encodedFailure = dataConverter.exceptionToFailure(applicationFailure); + assertEquals(1, encodedFailure.getApplicationFailureInfo().getDetails().getPayloadsCount()); + + ApplicationFailure decodedFailure = + (ApplicationFailure) dataConverter.failureToException(encodedFailure); + TypeToken>> detailsType = + new TypeToken>>() {}; + assertEquals(details, decodedFailure.getDetails().get(0, List.class, detailsType.getType())); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java index 380cbd7357..4c528646df 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java @@ -10,6 +10,7 @@ import io.temporal.common.converter.DataConverterException; import io.temporal.common.converter.DefaultDataConverter; import io.temporal.common.converter.EncodedValuesTest; +import io.temporal.common.converter.PayloadValidationException; import io.temporal.failure.ApplicationFailure; import io.temporal.payload.codec.PayloadCodecException; import java.lang.reflect.GenericArrayType; @@ -128,10 +129,9 @@ public void testDeserializeApplicationFailureIsPropagatedAsIs() { @Test public void testDeserializeNonRetryablePayloadValidationErrorIsNonRetryableBadRequest() { // The converter understood the input and rejected it, which makes this the caller's fault. - RuntimeException cause = new RuntimeException("field 'name' must not be empty"); ApplicationFailure original = - ApplicationFailure.newNonRetryableFailureWithCause( - "invalid input", PayloadSerializer.PAYLOAD_VALIDATION_ERROR_TYPE, cause); + PayloadValidationException.newPayloadValidationException( + Collections.singletonList(Collections.singletonMap("name", "must not be empty"))); PayloadSerializer serializer = failingSerializer(null, original); PayloadSerializer.Content content = payloadSerializer.serialize("test"); @@ -149,8 +149,8 @@ public void testDeserializeNonRetryablePayloadValidationErrorIsNonRetryableBadRe Assert.assertSame(original, causeFailure); Assert.assertEquals(PayloadSerializer.PAYLOAD_VALIDATION_ERROR_TYPE, causeFailure.getType()); Assert.assertTrue(causeFailure.isNonRetryable()); - Assert.assertEquals("invalid input", causeFailure.getOriginalMessage()); - Assert.assertSame(cause, causeFailure.getCause()); + Assert.assertEquals("Payload validation failed", causeFailure.getOriginalMessage()); + Assert.assertEquals(1, causeFailure.getDetails().getSize()); } @Test diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java index e3f4bc179c..ac826b94e2 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java @@ -13,6 +13,7 @@ import io.temporal.client.WorkflowFailedException; import io.temporal.common.converter.DataConverter; import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.common.converter.PayloadValidationException; import io.temporal.failure.ApplicationFailure; import io.temporal.failure.NexusOperationFailure; import io.temporal.failure.TimeoutFailure; @@ -22,6 +23,7 @@ import io.temporal.workflow.shared.TestWorkflows.TestWorkflow1; import java.lang.reflect.Type; import java.time.Duration; +import java.util.Collections; import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; import javax.annotation.Nonnull; @@ -45,7 +47,6 @@ public class OperationInputDeserializationErrorPropagationTest { "non-retryable-payload-validation-error"; private static final String RETRYABLE_PAYLOAD_VALIDATION_ERROR = "retryable-payload-validation-error"; - private static final String PAYLOAD_VALIDATION_ERROR_TYPE = "PayloadValidationError"; private static final String CODEC_FAILURE = "codec-failure"; private static final AtomicInteger deserializeAttempts = new AtomicInteger(); @@ -145,7 +146,7 @@ public void nonRetryablePayloadValidationErrorBecomesNonRetryableBadRequest() { Assert.assertEquals("PayloadValidationError", ((ApplicationFailure) cause).getType()); Assert.assertTrue( "expected the converter's message on the cause, got " + cause.getMessage(), - cause.getMessage().contains("intentional failure")); + cause.getMessage().contains("Payload validation failed")); Assert.assertEquals(1, deserializeAttempts.get()); Assert.assertEquals(0, operationInvocations.get()); @@ -288,11 +289,10 @@ private static RuntimeException failureFor(String mode) { case RETRYABLE_APPLICATION_FAILURE: return ApplicationFailure.newFailure("intentional failure", "TestFailure"); case NON_RETRYABLE_PAYLOAD_VALIDATION_ERROR: - return ApplicationFailure.newNonRetryableFailure( - "intentional failure", PAYLOAD_VALIDATION_ERROR_TYPE); + return PayloadValidationException.newPayloadValidationException( + Collections.singletonList("intentional validation failure")); case RETRYABLE_PAYLOAD_VALIDATION_ERROR: - return ApplicationFailure.newFailure( - "intentional failure", PAYLOAD_VALIDATION_ERROR_TYPE); + return ApplicationFailure.newFailure("intentional failure", "PayloadValidationError"); case CODEC_FAILURE: return new PayloadCodecException("intentional failure"); default: