Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure that custom Jackson modules work in dev-mode #44373

Merged
merged 1 commit into from
Nov 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<StringAndInt> {

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<StringAndInt> {

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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import io.quarkus.arc.deployment.SyntheticBeanBuildItem;
import io.quarkus.arc.deployment.UnremovableBeanBuildItem;
import io.quarkus.bootstrap.classloading.QuarkusClassLoader;
import io.quarkus.deployment.IsDevelopment;
import io.quarkus.deployment.annotations.BuildProducer;
import io.quarkus.deployment.annotations.BuildStep;
import io.quarkus.deployment.annotations.ExecutionTime;
Expand Down Expand Up @@ -286,6 +287,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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,6 +12,8 @@
*/
public class QuarkusJacksonFactory implements JsonFactory {

private static final AtomicInteger COUNTER = new AtomicInteger();

@Override
public JsonCodec codec() {
JsonCodec codec;
Expand All @@ -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();
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Comment on lines 37 to +57
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will the QuarkusJacksonJsonCodec always be loaded by Vert.x? Because if so, I wonder if we should populate a new mapper directly instead of resetting it.
That being said I'm not entirely sure we could safely drop some of the guards but wanted to push the idea in case you didn't think about it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name of the method is probably confusing here - what it does is actually create a new ObjectMapper used by Vert.x. Do you want me to change the method as to make it easier for us to understand next time we stumble upon the code?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No I understand what the method does.

What I was wondering is if the reset() could populate() the default mapper instead of populating it. Maybe in a synchronized method so that we could drop the volatile.

That would make sense only if the codec is always initialized though. Otherwise we might pay the initialization cost for nothing.

It might be a bad idea though, I will let you decide what's best.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could populate() the default mapper instead of populating it. Maybe in a synchronized method so that we could drop the volatile.

I want to avoid that because populating means extracting from Arc, which I am not 100% sure would give the proper bean at this point.
Whereas the way it's done now, the loading of the mapper happens in the same "timing" as if there no reload.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, sure, it makes perfect sense. We can revisit if we see it creates some performance issues but in this case, we will have to be very careful as to how operations are ordered.

ArcContainer container = Arc.container();
if (container == null) {
// this can happen in QuarkusUnitTest
Expand Down Expand Up @@ -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;
Expand All @@ -83,7 +103,7 @@ private ObjectMapper prettyMapper() {
@SuppressWarnings("unchecked")
@Override
public <T> T fromValue(Object json, Class<T> clazz) {
T value = QuarkusJacksonJsonCodec.mapper.convertValue(json, clazz);
T value = QuarkusJacksonJsonCodec.mapper().convertValue(json, clazz);
if (clazz == Object.class) {
value = (T) adapt(value);
}
Expand All @@ -102,7 +122,7 @@ public <T> T fromBuffer(Buffer buf, Class<T> 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);
Expand All @@ -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);
}
Expand All @@ -122,7 +142,7 @@ public static <T> T fromParser(JsonParser parser, Class<T> 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);
Expand All @@ -141,7 +161,7 @@ public static <T> T fromParser(JsonParser parser, Class<T> 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);
Expand All @@ -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);
Expand Down
Loading