Skip to content

Commit

Permalink
Merge pull request #32393 from yrodiere/jackson-json-rpc-service
Browse files Browse the repository at this point in the history
Allow relying on Jackson-databind in Dev UI JsonRpc services regardless of runtime dependencies
  • Loading branch information
phillip-kruger authored Apr 5, 2023
2 parents 3739b5d + 8132080 commit 2223026
Show file tree
Hide file tree
Showing 17 changed files with 554 additions and 113 deletions.
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 @@ -334,6 +337,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
Loading

0 comments on commit 2223026

Please sign in to comment.