From ef112b8589d955d0d241fe90137b949ca36dabfa Mon Sep 17 00:00:00 2001 From: Georgios Andrianakis Date: Thu, 7 Nov 2024 20:22:31 +0200 Subject: [PATCH] Ensure that custom Jackson modules work in dev-mode Fixes: #44231 --- .../test/CustomModuleLiveReloadTest.java | 144 ++++++++++++++++++ .../core/deployment/VertxCoreProcessor.java | 8 + .../vertx/core/runtime/VertxCoreRecorder.java | 10 ++ .../jackson/QuarkusJacksonFactory.java | 12 ++ .../jackson/QuarkusJacksonJsonCodec.java | 36 ++++- 5 files changed, 202 insertions(+), 8 deletions(-) create mode 100644 extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/CustomModuleLiveReloadTest.java diff --git a/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/CustomModuleLiveReloadTest.java b/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/CustomModuleLiveReloadTest.java new file mode 100644 index 00000000000000..b8db1861269700 --- /dev/null +++ b/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/CustomModuleLiveReloadTest.java @@ -0,0 +1,144 @@ +package io.quarkus.resteasy.reactive.jackson.deployment.test; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.containsString; + +import java.io.IOException; + +import jakarta.inject.Singleton; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; + +import io.quarkus.jackson.ObjectMapperCustomizer; +import io.quarkus.test.QuarkusDevModeTest; +import io.vertx.core.json.JsonArray; + +public class CustomModuleLiveReloadTest { + + @RegisterExtension + static final QuarkusDevModeTest TEST = new QuarkusDevModeTest() + .withApplicationRoot((jar) -> jar + .addClasses(Resource.class, StringAndInt.class, StringAndIntSerializer.class, + StringAndIntDeserializer.class, Customizer.class) + .addAsResource(new StringAsset("index content"), "META-INF/resources/index.html")); + + @Test + void test() { + assertResponse(); + + // force reload + TEST.addResourceFile("META-INF/resources/index.html", "html content"); + + assertResponse(); + } + + private static void assertResponse() { + given().accept("application/json").get("test/array") + .then() + .statusCode(200) + .body(containsString("first:1"), containsString("second:2")); + } + + @Path("test") + public static class Resource { + + @Path("array") + @GET + @Produces(MediaType.APPLICATION_JSON) + public JsonArray array() { + var array = new JsonArray(); + array.add(new StringAndInt("first", 1)); + array.add(new StringAndInt("second", 2)); + return array; + } + } + + public static class StringAndInt { + private final String stringValue; + private final int intValue; + + public StringAndInt(String s, int i) { + this.stringValue = s; + this.intValue = i; + } + + public static StringAndInt parse(String value) { + if (value == null) { + return null; + } + int dot = value.indexOf(':'); + if (-1 == dot) { + throw new IllegalArgumentException(value); + } + try { + return new StringAndInt(value.substring(0, dot), Integer.parseInt(value.substring(dot + 1))); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(value, e); + } + } + + public String format() { + return this.stringValue + ":" + intValue; + } + } + + public static class StringAndIntSerializer extends StdSerializer { + + public StringAndIntSerializer() { + super(StringAndInt.class); + } + + @Override + public void serialize(StringAndInt value, JsonGenerator gen, SerializerProvider provider) throws IOException { + if (value == null) + gen.writeNull(); + else { + gen.writeString(value.format()); + } + } + } + + public static class StringAndIntDeserializer extends StdDeserializer { + + public StringAndIntDeserializer() { + super(StringAndInt.class); + } + + @Override + public StringAndInt deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + if (p.currentToken() == JsonToken.VALUE_STRING) { + return StringAndInt.parse(p.getText()); + } else if (p.currentToken() == JsonToken.VALUE_NULL) { + return null; + } + return null; + } + } + + @Singleton + public static class Customizer implements ObjectMapperCustomizer { + @Override + public void customize(ObjectMapper objectMapper) { + var m = new SimpleModule("test"); + m.addSerializer(StringAndInt.class, new StringAndIntSerializer()); + m.addDeserializer(StringAndInt.class, new StringAndIntDeserializer()); + objectMapper.registerModule(m); + } + } +} diff --git a/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/core/deployment/VertxCoreProcessor.java b/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/core/deployment/VertxCoreProcessor.java index ab09839b74d159..bd6a2cd28de9bc 100644 --- a/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/core/deployment/VertxCoreProcessor.java +++ b/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/core/deployment/VertxCoreProcessor.java @@ -1,5 +1,6 @@ package io.quarkus.vertx.core.deployment; +import io.quarkus.deployment.IsDevelopment; import java.io.IOException; import java.lang.management.ManagementFactory; import java.net.InetAddress; @@ -50,6 +51,7 @@ import io.quarkus.deployment.builditem.nativeimage.NativeImageResourceBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.logging.LogCleanupFilterBuildItem; +import io.quarkus.deployment.pkg.steps.NativeOrNativeSourcesBuild; import io.quarkus.gizmo.Gizmo; import io.quarkus.netty.deployment.EventLoopSupplierBuildItem; import io.quarkus.runtime.ThreadPoolConfig; @@ -286,6 +288,12 @@ ContextHandlerBuildItem createVertxContextHandlers(VertxCoreRecorder recorder, V return new ContextHandlerBuildItem(recorder.executionContextHandler(buildConfig.customizeArcContext())); } + @BuildStep(onlyIf = IsDevelopment.class) + @Record(ExecutionTime.RUNTIME_INIT) + public void resetMapper(VertxCoreRecorder recorder, ShutdownContextBuildItem shutdown) { + recorder.resetMapper(shutdown); + } + private void handleBlockingWarningsInDevOrTestMode() { try { Filter debuggerFilter = createDebuggerFilter(); diff --git a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/core/runtime/VertxCoreRecorder.java b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/core/runtime/VertxCoreRecorder.java index d678e2dfa3ab0b..15ab4cd34fc982 100644 --- a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/core/runtime/VertxCoreRecorder.java +++ b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/core/runtime/VertxCoreRecorder.java @@ -49,6 +49,7 @@ import io.quarkus.vertx.core.runtime.context.VertxContextSafetyToggle; import io.quarkus.vertx.mdc.provider.LateBoundMDCProvider; import io.quarkus.vertx.runtime.VertxCurrentContextFactory; +import io.quarkus.vertx.runtime.jackson.QuarkusJacksonFactory; import io.vertx.core.AsyncResult; import io.vertx.core.Context; import io.vertx.core.Handler; @@ -583,6 +584,15 @@ public Thread newThread(Runnable runnable) { }; } + public void resetMapper(ShutdownContext shutdown) { + shutdown.addShutdownTask(new Runnable() { + @Override + public void run() { + QuarkusJacksonFactory.reset(); + } + }); + } + private static void setNewThreadTccl(VertxThread thread) { ClassLoader cl = VertxCoreRecorder.currentDevModeNewThreadCreationClassLoader; if (cl == null) { diff --git a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/QuarkusJacksonFactory.java b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/QuarkusJacksonFactory.java index 665cc6ca0697e9..8847fcfb59f303 100644 --- a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/QuarkusJacksonFactory.java +++ b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/QuarkusJacksonFactory.java @@ -1,5 +1,7 @@ package io.quarkus.vertx.runtime.jackson; +import java.util.concurrent.atomic.AtomicInteger; + import io.vertx.core.json.jackson.DatabindCodec; import io.vertx.core.json.jackson.JacksonCodec; import io.vertx.core.spi.JsonFactory; @@ -10,6 +12,8 @@ */ public class QuarkusJacksonFactory implements JsonFactory { + private static final AtomicInteger COUNTER = new AtomicInteger(); + @Override public JsonCodec codec() { JsonCodec codec; @@ -25,7 +29,15 @@ public JsonCodec codec() { codec = new JacksonCodec(); } } + COUNTER.incrementAndGet(); return codec; } + public static void reset() { + // if we blindly reset, we could get NCDFE because Jackson classes would not have been loaded + if (COUNTER.get() > 0) { + QuarkusJacksonJsonCodec.reset(); + } + } + } diff --git a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/QuarkusJacksonJsonCodec.java b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/QuarkusJacksonJsonCodec.java index 995812897da5b8..1e02b5e8fef81e 100644 --- a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/QuarkusJacksonJsonCodec.java +++ b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/QuarkusJacksonJsonCodec.java @@ -30,11 +30,31 @@ */ class QuarkusJacksonJsonCodec implements JsonCodec { - private static final ObjectMapper mapper; + private static volatile ObjectMapper mapper; // we don't want to create this unless it's absolutely necessary (and it rarely is) private static volatile ObjectMapper prettyMapper; static { + populateMapper(); + } + + public static void reset() { + mapper = null; + prettyMapper = null; + } + + private static ObjectMapper mapper() { + if (mapper == null) { + synchronized (QuarkusJacksonJsonCodec.class) { + if (mapper == null) { + populateMapper(); + } + } + } + return mapper; + } + + private static void populateMapper() { ArcContainer container = Arc.container(); if (container == null) { // this can happen in QuarkusUnitTest @@ -74,7 +94,7 @@ class QuarkusJacksonJsonCodec implements JsonCodec { private ObjectMapper prettyMapper() { if (prettyMapper == null) { - prettyMapper = mapper.copy(); + prettyMapper = mapper().copy(); prettyMapper.configure(SerializationFeature.INDENT_OUTPUT, true); } return prettyMapper; @@ -83,7 +103,7 @@ private ObjectMapper prettyMapper() { @SuppressWarnings("unchecked") @Override public T fromValue(Object json, Class clazz) { - T value = QuarkusJacksonJsonCodec.mapper.convertValue(json, clazz); + T value = QuarkusJacksonJsonCodec.mapper().convertValue(json, clazz); if (clazz == Object.class) { value = (T) adapt(value); } @@ -102,7 +122,7 @@ public T fromBuffer(Buffer buf, Class clazz) throws DecodeException { public static JsonParser createParser(Buffer buf) { try { - return QuarkusJacksonJsonCodec.mapper.getFactory() + return QuarkusJacksonJsonCodec.mapper().getFactory() .createParser((InputStream) new ByteBufInputStream(buf.getByteBuf())); } catch (IOException e) { throw new DecodeException("Failed to decode:" + e.getMessage(), e); @@ -111,7 +131,7 @@ public static JsonParser createParser(Buffer buf) { public static JsonParser createParser(String str) { try { - return QuarkusJacksonJsonCodec.mapper.getFactory().createParser(str); + return QuarkusJacksonJsonCodec.mapper().getFactory().createParser(str); } catch (IOException e) { throw new DecodeException("Failed to decode:" + e.getMessage(), e); } @@ -122,7 +142,7 @@ public static T fromParser(JsonParser parser, Class type) throws DecodeEx T value; JsonToken remaining; try { - value = QuarkusJacksonJsonCodec.mapper.readValue(parser, type); + value = QuarkusJacksonJsonCodec.mapper().readValue(parser, type); remaining = parser.nextToken(); } catch (Exception e) { throw new DecodeException("Failed to decode:" + e.getMessage(), e); @@ -141,7 +161,7 @@ public static T fromParser(JsonParser parser, Class type) throws DecodeEx @Override public String toString(Object object, boolean pretty) throws EncodeException { try { - ObjectMapper mapper = pretty ? prettyMapper() : QuarkusJacksonJsonCodec.mapper; + ObjectMapper mapper = pretty ? prettyMapper() : QuarkusJacksonJsonCodec.mapper(); return mapper.writeValueAsString(object); } catch (Exception e) { throw new EncodeException("Failed to encode as JSON: " + e.getMessage(), e); @@ -151,7 +171,7 @@ public String toString(Object object, boolean pretty) throws EncodeException { @Override public Buffer toBuffer(Object object, boolean pretty) throws EncodeException { try { - ObjectMapper mapper = pretty ? prettyMapper() : QuarkusJacksonJsonCodec.mapper; + ObjectMapper mapper = pretty ? prettyMapper() : QuarkusJacksonJsonCodec.mapper(); return Buffer.buffer(mapper.writeValueAsBytes(object)); } catch (Exception e) { throw new EncodeException("Failed to encode as JSON: " + e.getMessage(), e);