diff --git a/temporal-sdk/build.gradle b/temporal-sdk/build.gradle index d981a0c20a..94526f263c 100644 --- a/temporal-sdk/build.gradle +++ b/temporal-sdk/build.gradle @@ -63,6 +63,9 @@ dependencies { java17Implementation files(sourceSets.main.output.classesDirs) { builtBy compileJava } java17Implementation files({ sourceSets.main.compileClasspath }) java17CompileOnly "tools.jackson.core:jackson-databind:$jackson3Version" + // The Java 8 stub must declare the same public API as the Java 17 class (JEP 238), so it needs + // the Jackson 3 types at compile time only. + compileOnly "tools.jackson.core:jackson-databind:$jackson3Version" java21Implementation files(sourceSets.main.output.classesDirs) { builtBy compileJava } } @@ -181,9 +184,8 @@ test { } // On Java 17+, prepend java17 classes to all test classpaths so that Class.forName finds -// the real Jackson3JsonPayloadConverter instead of the Java 8 stub. This lets us test -// the present-java17-but-absent-jackson3 behavior (NoClassDefFoundError) in the same -// test that tests the Java 8 stub behavior (UnsupportedOperationException). +// the real Jackson3JsonPayloadConverter instead of the Java 8 stub, matching what the +// multi-release jar resolves to at runtime. tasks.withType(Test).configureEach { dependsOn compileJava17Java doFirst { @@ -236,9 +238,8 @@ testing { jackson3Tests(JvmTestSuite) { dependencies { // java17 output must come before project() (added by configureEach) so that - // the compiler and runtime see the real Jackson3JsonPayloadConverter — which - // has a wider API than the Java 8 stub (newDefaultJsonMapper, JsonMapper - // constructor) because the stub can't reference Jackson 3 types. + // the compiler and runtime see the real Jackson3JsonPayloadConverter rather + // than the Java 8 stub, which only throws. implementation files(sourceSets.java17.output.classesDirs) { builtBy compileJava17Java } implementation "tools.jackson.core:jackson-databind:$jackson3Version" } @@ -250,6 +251,7 @@ testing { languageVersion = JavaLanguageVersion.of(project.property("testJavaVersion") as int) } } + systemProperty 'temporal.sdk.mainClassesDirs', sourceSets.main.output.classesDirs.asPath shouldRunAfter(test) } } diff --git a/temporal-sdk/src/jackson3Tests/java/io/temporal/common/converter/Jackson3JsonPayloadConverterApiTest.java b/temporal-sdk/src/jackson3Tests/java/io/temporal/common/converter/Jackson3JsonPayloadConverterApiTest.java new file mode 100644 index 0000000000..b3587f3d4d --- /dev/null +++ b/temporal-sdk/src/jackson3Tests/java/io/temporal/common/converter/Jackson3JsonPayloadConverterApiTest.java @@ -0,0 +1,64 @@ +package io.temporal.common.converter; + +import static org.junit.Assert.assertEquals; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.nio.file.Files; +import java.util.Set; +import java.util.TreeSet; +import org.junit.Test; + +/** + * Verifies that the Java 8 stub of {@link Jackson3JsonPayloadConverter} declares the same public + * API as the Java 17 class, as required for versioned entries of a multi-release jar. Tools that + * compile against the base entry otherwise cannot resolve members that exist only in the Java 17 + * class. + */ +public class Jackson3JsonPayloadConverterApiTest { + + private static final String CLASS_FILE = + Jackson3JsonPayloadConverter.class.getName().replace('.', '/') + ".class"; + + @Test + public void testStubDeclaresTheSamePublicApi() throws Exception { + byte[] stub = readStub(); + Class stubClass = + new ClassLoader(getClass().getClassLoader()) { + Class define() { + return defineClass(null, stub, 0, stub.length); + } + }.define(); + + assertEquals(publicApi(Jackson3JsonPayloadConverter.class), publicApi(stubClass)); + } + + private static byte[] readStub() throws IOException { + String dirs = System.getProperty("temporal.sdk.mainClassesDirs"); + for (String dir : dirs.split(File.pathSeparator, -1)) { + File classFile = new File(dir, CLASS_FILE); + if (classFile.isFile()) { + return Files.readAllBytes(classFile.toPath()); + } + } + throw new AssertionError("stub not found in " + dirs); + } + + private static Set publicApi(Class clazz) { + Set api = new TreeSet<>(); + for (Constructor constructor : clazz.getDeclaredConstructors()) { + if (Modifier.isPublic(constructor.getModifiers())) { + api.add(constructor.toGenericString()); + } + } + for (Method method : clazz.getDeclaredMethods()) { + if (Modifier.isPublic(method.getModifiers()) && !method.isSynthetic()) { + api.add(method.toGenericString()); + } + } + return api; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/common/converter/Jackson3JsonPayloadConverter.java b/temporal-sdk/src/main/java/io/temporal/common/converter/Jackson3JsonPayloadConverter.java index cd4095349c..bfea494d61 100644 --- a/temporal-sdk/src/main/java/io/temporal/common/converter/Jackson3JsonPayloadConverter.java +++ b/temporal-sdk/src/main/java/io/temporal/common/converter/Jackson3JsonPayloadConverter.java @@ -4,6 +4,7 @@ import io.temporal.common.Experimental; import java.lang.reflect.Type; import java.util.Optional; +import tools.jackson.databind.json.JsonMapper; /** * A {@link PayloadConverter} that uses Jackson 3.x for JSON serialization/deserialization. This @@ -32,6 +33,14 @@ public Jackson3JsonPayloadConverter(boolean jackson2Compat) { throw new UnsupportedOperationException(UNSUPPORTED_MSG); } + public Jackson3JsonPayloadConverter(JsonMapper mapper) { + throw new UnsupportedOperationException(UNSUPPORTED_MSG); + } + + public static JsonMapper newDefaultJsonMapper(boolean jackson2Compat) { + throw new UnsupportedOperationException(UNSUPPORTED_MSG); + } + @Override public String getEncodingType() { throw new UnsupportedOperationException(UNSUPPORTED_MSG); diff --git a/temporal-sdk/src/test/java/io/temporal/common/converter/JacksonJsonPayloadConverterTest.java b/temporal-sdk/src/test/java/io/temporal/common/converter/JacksonJsonPayloadConverterTest.java index 5154bdc0ad..6950677d7b 100644 --- a/temporal-sdk/src/test/java/io/temporal/common/converter/JacksonJsonPayloadConverterTest.java +++ b/temporal-sdk/src/test/java/io/temporal/common/converter/JacksonJsonPayloadConverterTest.java @@ -5,7 +5,6 @@ import static org.junit.Assert.fail; import io.temporal.api.common.v1.Payloads; -import java.lang.reflect.InvocationTargetException; import java.time.Instant; import java.util.Objects; import java.util.Optional; @@ -25,27 +24,11 @@ public void testSetDefaultAsJackson3ThrowsWithoutJackson3() { JacksonJsonPayloadConverter.setDefaultAsJackson3(true, true); fail("Expected IllegalStateException"); } catch (IllegalStateException e) { - // On Java 8: Class.forName finds the stub, whose constructor throws - // UnsupportedOperationException → wrapped in InvocationTargetException by reflection. - // On Java 17+: Class.forName finds the real impl (java17 classes are on the classpath) - // but Jackson 3 types are absent, so class loading throws NoClassDefFoundError directly. + // Both the Java 8 stub and the Java 17 class reference Jackson 3 types, so reflective + // constructor lookup fails while linking when Jackson 3 is absent. Throwable cause = e.getCause(); - String specVersion = System.getProperty("java.specification.version"); - int majorVersion = - specVersion.startsWith("1.") - ? Integer.parseInt(specVersion.substring(2)) - : Integer.parseInt(specVersion); - if (majorVersion >= 17) { - assertTrue( - "Expected NoClassDefFoundError, got: " + cause, cause instanceof NoClassDefFoundError); - } else { - assertTrue( - "Expected InvocationTargetException, got: " + cause, - cause instanceof InvocationTargetException); - assertTrue( - "Expected UnsupportedOperationException, got: " + cause.getCause(), - cause.getCause() instanceof UnsupportedOperationException); - } + assertTrue( + "Expected NoClassDefFoundError, got: " + cause, cause instanceof NoClassDefFoundError); } }