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

Allow relying on Jackson-databind in Dev UI JsonRpc services regardless of runtime dependencies #32393

Merged
merged 2 commits into from
Apr 5, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package io.quarkus.dev.console;

import java.util.Map;

/**
* Creates "links" to objects between deployment and runtime,
* essentially exposing the same interface but on a different classloader.
* <p>
* This implies all communication must go through JDK classes, so the transfer involves Maps, Functions, ...
* Yes this is awful. No there's no better solution ATM.
* Ideally we'd automate this through bytecode generation,
* but feasibility is uncertain, and we'd need a volunteer who has time for that.
* <p>
* Implementations should live in the runtime module.
* To transfer {@link #createLinkData(Object) link data} between deployment and runtime,
* see {@link DevConsoleManager#setGlobal(String, Object)} and {@link DevConsoleManager#getGlobal(String)}.
*/
public interface DeploymentLinker<T> {

/**
* @param object An object implementing class {@code T} in either the current classloader.
* @return A classloader-independent map containing Functions, Suppliers, etc.
* giving access to the object's methods,
* which will be passed to {@link #createLink(Map)} from the other classloader.
*/
Map<String, ?> createLinkData(T object);

/**
* @param linkData The result of calling {@link #createLinkData(Object)}.
* @return An object implementing class {@code T} in the current classloader
* and redirecting calls to the Functions, Suppliers, etc. from {@code linkData},
* thereby linking to the implementation in its original classloader.
*/
T createLink(Map<String, ?> linkData);

}
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,15 @@
import io.quarkus.deployment.builditem.CombinedIndexBuildItem;
import io.quarkus.deployment.builditem.ShutdownContextBuildItem;
import io.quarkus.deployment.pkg.builditem.CurateOutcomeBuildItem;
import io.quarkus.dev.console.DevConsoleManager;
import io.quarkus.devui.deployment.extension.Codestart;
import io.quarkus.devui.deployment.extension.Extension;
import io.quarkus.devui.deployment.jsonrpc.DevUIDatabindCodec;
import io.quarkus.devui.runtime.DevUIRecorder;
import io.quarkus.devui.runtime.comms.JsonRpcRouter;
import io.quarkus.devui.runtime.jsonrpc.JsonRpcMethod;
import io.quarkus.devui.runtime.jsonrpc.JsonRpcMethodName;
import io.quarkus.devui.runtime.jsonrpc.json.JsonMapper;
import io.quarkus.devui.spi.DevUIContent;
import io.quarkus.devui.spi.JsonRPCProvidersBuildItem;
import io.quarkus.devui.spi.buildtime.StaticContentBuildItem;
Expand Down Expand Up @@ -329,6 +332,8 @@ void createJsonRpcRouter(DevUIRecorder recorder,
Map<String, Map<JsonRpcMethodName, JsonRpcMethod>> extensionMethodsMap = jsonRPCMethodsBuildItem
.getExtensionMethodsMap();

DevConsoleManager.setGlobal(DevUIRecorder.DEV_MANAGER_GLOBALS_JSON_MAPPER_FACTORY,
JsonMapper.Factory.deploymentLinker().createLinkData(new DevUIDatabindCodec.Factory()));
recorder.createJsonRpcRouter(beanContainer.getValue(), extensionMethodsMap);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package io.quarkus.devui.deployment.jsonrpc;

import java.io.Closeable;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

import com.fasterxml.jackson.annotation.JsonInclude;
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.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.module.SimpleModule;

import io.quarkus.devui.runtime.jsonrpc.json.JsonMapper;
import io.quarkus.devui.runtime.jsonrpc.json.JsonTypeAdapter;
import io.quarkus.vertx.runtime.jackson.ByteArrayDeserializer;
import io.quarkus.vertx.runtime.jackson.ByteArraySerializer;
import io.quarkus.vertx.runtime.jackson.InstantDeserializer;
import io.quarkus.vertx.runtime.jackson.InstantSerializer;
import io.vertx.core.json.DecodeException;
import io.vertx.core.json.EncodeException;

public class DevUIDatabindCodec implements JsonMapper {
private final ObjectMapper mapper;
private final ObjectMapper prettyMapper;
private final Function<Map<String, Object>, ?> runtimeObjectDeserializer;
private final Function<List<?>, ?> runtimeArrayDeserializer;

private DevUIDatabindCodec(ObjectMapper mapper,
Function<Map<String, Object>, ?> runtimeObjectDeserializer,
Function<List<?>, ?> runtimeArrayDeserializer) {
this.mapper = mapper;
prettyMapper = mapper.copy();
prettyMapper.configure(SerializationFeature.INDENT_OUTPUT, true);
this.runtimeObjectDeserializer = runtimeObjectDeserializer;
this.runtimeArrayDeserializer = runtimeArrayDeserializer;
}

@SuppressWarnings("unchecked")
@Override
public <T> T fromValue(Object json, Class<T> clazz) {
T value = mapper.convertValue(json, clazz);
if (clazz == Object.class) {
value = (T) adapt(value);
}
return value;
}

@Override
public <T> T fromString(String str, Class<T> clazz) throws DecodeException {
return fromParser(createParser(str), clazz);
}

private JsonParser createParser(String str) {
try {
return mapper.getFactory().createParser(str);
} catch (IOException e) {
throw new DecodeException("Failed to decode:" + e.getMessage(), e);
}
}

@SuppressWarnings("unchecked")
private <T> T fromParser(JsonParser parser, Class<T> type) throws DecodeException {
T value;
JsonToken remaining;
try {
value = mapper.readValue(parser, type);
remaining = parser.nextToken();
} catch (Exception e) {
throw new DecodeException("Failed to decode:" + e.getMessage(), e);
} finally {
close(parser);
}
if (remaining != null) {
throw new DecodeException("Unexpected trailing token");
}
if (type == Object.class) {
value = (T) adapt(value);
}
return value;
}

@Override
public String toString(Object object, boolean pretty) throws EncodeException {
try {
ObjectMapper theMapper = pretty ? prettyMapper : mapper;
return theMapper.writeValueAsString(object);
} catch (Exception e) {
throw new EncodeException("Failed to encode as JSON: " + e.getMessage(), e);
}
}

private static void close(Closeable parser) {
try {
parser.close();
} catch (IOException ignore) {
}
}

private Object adapt(Object o) {
try {
if (o instanceof List) {
List<?> list = (List<?>) o;
return runtimeArrayDeserializer.apply(list);
} else if (o instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> map = (Map<String, Object>) o;
return runtimeObjectDeserializer.apply(map);
}
return o;
} catch (Exception e) {
throw new DecodeException("Failed to decode: " + e.getMessage());
}
}

public static final class Factory implements JsonMapper.Factory {
@Override
public JsonMapper create(JsonTypeAdapter<?, Map<String, Object>> jsonObjectAdapter,
JsonTypeAdapter<?, List<?>> jsonArrayAdapter, JsonTypeAdapter<?, String> bufferAdapter) {
// We want our own mapper, separate from the user-configured one.
ObjectMapper mapper = new ObjectMapper();

// Non-standard JSON but we allow C style comments in our JSON
mapper.configure(JsonParser.Feature.ALLOW_COMMENTS, true);
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);

SimpleModule module = new SimpleModule("vertx-module-common");
module.addSerializer(Instant.class, new InstantSerializer());
module.addDeserializer(Instant.class, new InstantDeserializer());
module.addSerializer(byte[].class, new ByteArraySerializer());
module.addDeserializer(byte[].class, new ByteArrayDeserializer());
mapper.registerModule(module);

SimpleModule runtimeModule = new SimpleModule("vertx-module-runtime");
addAdapterToObject(runtimeModule, jsonObjectAdapter);
addAdapterToObject(runtimeModule, jsonArrayAdapter);
addAdapterToString(runtimeModule, bufferAdapter);
mapper.registerModule(runtimeModule);

return new DevUIDatabindCodec(mapper, jsonObjectAdapter.deserializer, jsonArrayAdapter.deserializer);
}

private static <T, S> void addAdapterToObject(SimpleModule module, JsonTypeAdapter<T, S> adapter) {
module.addSerializer(adapter.type, new JsonSerializer<>() {
@Override
public void serialize(T value, JsonGenerator jgen, SerializerProvider provider) throws IOException {
jgen.writeObject(adapter.serializer.apply(value));
}
});
}

private static <T> void addAdapterToString(SimpleModule module, JsonTypeAdapter<T, String> adapter) {
module.addSerializer(adapter.type, new JsonSerializer<>() {
@Override
public void serialize(T value, JsonGenerator jgen, SerializerProvider provider) throws IOException {
jgen.writeString(adapter.serializer.apply(value));
}
});
module.addDeserializer(adapter.type, new JsonDeserializer<T>() {
@Override
public T deserialize(JsonParser parser, DeserializationContext ctxt) throws IOException {
return adapter.deserializer.apply(parser.getText());
}
});
}
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package io.quarkus.devui.runtime;

import static io.quarkus.vertx.runtime.jackson.JsonUtil.BASE64_DECODER;
import static io.quarkus.vertx.runtime.jackson.JsonUtil.BASE64_ENCODER;

import java.io.IOException;
import java.net.URL;
import java.nio.file.FileVisitResult;
Expand All @@ -15,19 +18,26 @@
import org.jboss.logging.Logger;

import io.quarkus.arc.runtime.BeanContainer;
import io.quarkus.dev.console.DevConsoleManager;
import io.quarkus.devui.runtime.comms.JsonRpcRouter;
import io.quarkus.devui.runtime.jsonrpc.JsonRpcMethod;
import io.quarkus.devui.runtime.jsonrpc.JsonRpcMethodName;
import io.quarkus.devui.runtime.jsonrpc.json.JsonMapper;
import io.quarkus.devui.runtime.jsonrpc.json.JsonTypeAdapter;
import io.quarkus.runtime.ShutdownContext;
import io.quarkus.runtime.annotations.Recorder;
import io.quarkus.vertx.http.runtime.devmode.FileSystemStaticHandler;
import io.quarkus.vertx.http.runtime.webjar.WebJarStaticHandler;
import io.vertx.core.Handler;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.RoutingContext;

@Recorder
public class DevUIRecorder {
private static final Logger LOG = Logger.getLogger(DevUIRecorder.class);
public static final String DEV_MANAGER_GLOBALS_JSON_MAPPER_FACTORY = "dev-ui-databind-codec-builder";

public void shutdownTask(ShutdownContext shutdownContext, String devUIBasePath) {
shutdownContext.addShutdownTask(new DeleteDirectoryRunnable(devUIBasePath));
Expand All @@ -37,6 +47,25 @@ public void createJsonRpcRouter(BeanContainer beanContainer,
Map<String, Map<JsonRpcMethodName, JsonRpcMethod>> extensionMethodsMap) {
JsonRpcRouter jsonRpcRouter = beanContainer.beanInstance(JsonRpcRouter.class);
jsonRpcRouter.populateJsonRPCMethods(extensionMethodsMap);
jsonRpcRouter.initializeCodec(createJsonMapper());
}

private JsonMapper createJsonMapper() {
// We use a codec defined in the deployment module
// because that module always has access to Jackson-Databind regardless of the application dependencies.
JsonMapper.Factory factory = JsonMapper.Factory.deploymentLinker().createLink(
DevConsoleManager.getGlobal(DEV_MANAGER_GLOBALS_JSON_MAPPER_FACTORY));
// We need to pass some information so that the mapper, who lives in the deployment classloader,
// knows how to deal with JsonObject/JsonArray/JsonBuffer, who live in the runtime classloader.
return factory.create(new JsonTypeAdapter<>(JsonObject.class, JsonObject::getMap, JsonObject::new),
new JsonTypeAdapter<JsonArray, List<?>>(JsonArray.class, JsonArray::getList, JsonArray::new),
new JsonTypeAdapter<>(Buffer.class, buffer -> BASE64_ENCODER.encodeToString(buffer.getBytes()), text -> {
try {
return Buffer.buffer(BASE64_DECODER.decode(text));
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Expected a base64 encoded byte array, got: " + text, e);
}
}));
}

public Handler<RoutingContext> communicationHandler() {
Expand Down