From 7c14d42b01429724dc70551a821536ffb05245f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yoann=20Rodi=C3=A8re?= Date: Tue, 9 May 2023 14:48:57 +0200 Subject: [PATCH] Make Elasticsearch Java Client's withJson(...) methods work in native mode --- .../ElasticsearchJavaClientProcessor.java | 9 +- .../ElasticsearchJavaClientFeature.java | 114 ++++++++++++++++++ .../it/elasticsearch/java/FruitResource.java | 8 ++ .../it/elasticsearch/java/FruitService.java | 11 ++ .../it/elasticsearch/FruitResourceTest.java | 50 +++++--- 5 files changed, 176 insertions(+), 16 deletions(-) create mode 100644 extensions/elasticsearch-java-client/runtime/src/main/java/io/quarkus/elasticsearch/javaclient/runtime/graalvm/ElasticsearchJavaClientFeature.java diff --git a/extensions/elasticsearch-java-client/deployment/src/main/java/io/quarkus/elasticsearch/javaclient/deployment/ElasticsearchJavaClientProcessor.java b/extensions/elasticsearch-java-client/deployment/src/main/java/io/quarkus/elasticsearch/javaclient/deployment/ElasticsearchJavaClientProcessor.java index d69b7b969c4eb..cf7d9c585718a 100644 --- a/extensions/elasticsearch-java-client/deployment/src/main/java/io/quarkus/elasticsearch/javaclient/deployment/ElasticsearchJavaClientProcessor.java +++ b/extensions/elasticsearch-java-client/deployment/src/main/java/io/quarkus/elasticsearch/javaclient/deployment/ElasticsearchJavaClientProcessor.java @@ -4,6 +4,7 @@ import io.quarkus.deployment.Feature; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.NativeImageFeatureBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.ServiceProviderBuildItem; @@ -33,8 +34,14 @@ ServiceProviderBuildItem serviceProvider() { } @BuildStep - ReflectiveClassBuildItem reflectiveClass() { + ReflectiveClassBuildItem jsonProvider() { return ReflectiveClassBuildItem.builder("org.eclipse.parsson.JsonProviderImpl").build(); } + @BuildStep + NativeImageFeatureBuildItem enableElasticsearchJavaClientFeature() { + return new NativeImageFeatureBuildItem( + "io.quarkus.elasticsearch.javaclient.runtime.graalvm.ElasticsearchJavaClientFeature"); + } + } diff --git a/extensions/elasticsearch-java-client/runtime/src/main/java/io/quarkus/elasticsearch/javaclient/runtime/graalvm/ElasticsearchJavaClientFeature.java b/extensions/elasticsearch-java-client/runtime/src/main/java/io/quarkus/elasticsearch/javaclient/runtime/graalvm/ElasticsearchJavaClientFeature.java new file mode 100644 index 0000000000000..563616ea3b238 --- /dev/null +++ b/extensions/elasticsearch-java-client/runtime/src/main/java/io/quarkus/elasticsearch/javaclient/runtime/graalvm/ElasticsearchJavaClientFeature.java @@ -0,0 +1,114 @@ +package io.quarkus.elasticsearch.javaclient.runtime.graalvm; + +import java.lang.reflect.Executable; +import java.lang.reflect.Modifier; + +import jakarta.json.stream.JsonParser; + +import org.graalvm.nativeimage.hosted.Feature; +import org.graalvm.nativeimage.hosted.RuntimeReflection; + +import co.elastic.clients.json.JsonpDeserializable; +import co.elastic.clients.json.JsonpMapper; +import co.elastic.clients.util.WithJsonObjectBuilderBase; + +/** + * Custom GraalVM feature to make Elasticsearch Java Client work in native mode. + *

+ * In particular, when applications rely on `WithJsonObjectBuilderBase#withJson(...)`, + * this automatically registers the corresponding Jsonp deserializers + * as accessed through reflection. + * We can't just register them all indiscriminately, + * because this would result in literally thousands of registrations, + * most of which would probably be useless. + */ +public final class ElasticsearchJavaClientFeature implements Feature { + + private static final String BUILDER_BASE_CLASS_NAME = "co.elastic.clients.util.WithJsonObjectBuilderBase"; + + /** + * To set this, add `-J-Dio.quarkus.elasticsearch.javaclient.graalvm.diagnostics=true` to the native-image parameters, + * e.g. pass this to Maven: + * -Dquarkus.native.additional-build-args=-J-Dio.quarkus.elasticsearch.javaclient.graalvm.diagnostics=true + */ + private static final boolean log = Boolean.getBoolean("io.quarkus.elasticsearch.javaclient.graalvm.diagnostics"); + + @Override + public void beforeAnalysis(BeforeAnalysisAccess access) { + Class builderClass = access.findClassByName(BUILDER_BASE_CLASS_NAME); + Executable withJsonMethod; + try { + withJsonMethod = builderClass.getMethod("withJson", JsonParser.class, JsonpMapper.class); + } catch (NoSuchMethodException e) { + throw new RuntimeException("Could not find " + BUILDER_BASE_CLASS_NAME + "#withJson(...);" + + " does the version of Elasticsearch Java Client match the version specified in the Quarkus BOM?"); + } + access.registerReachabilityHandler(this::onWithJsonReachable, withJsonMethod); + } + + private void onWithJsonReachable(DuringAnalysisAccess access) { + // The builder base class' withJson(...) method is reachable. + logf("%s#withJson(...) is reachable", BUILDER_BASE_CLASS_NAME); + + // We don't know on which builder subclass the withJson(...) method is called, + // so to be safe we consider every reachable builder subclass. + for (Class builderSubClass : access.reachableSubtypes(WithJsonObjectBuilderBase.class)) { + enableBuilderWithJson(builderSubClass, access); + } + } + + private void enableBuilderWithJson(Class builderSubClass, DuringAnalysisAccess access) { + // We don't care about abstract builder classes + if (Modifier.isAbstract(builderSubClass.getModifiers())) { + // Abstract builder classes may be top-level classes. + return; + } + + // When a builder's withJson() method is called, + // the implementation will (indirectly) access the enclosing class' + // _DESERIALIZER constant field through reflection, + // so we need to let GraalVM know. + + // Best-guess of the built class, given the coding coventions in Elasticsearch Java Client; + // ideally we'd resolve generics but it's hard and we don't have the right utils in our dependencies. + var builtClass = builderSubClass.getEnclosingClass(); + if (builtClass == null) { + logf("Could not guess the class built by %s", builderSubClass); + // Just ignore and hope this class doesn't matter. + return; + } + + var deserializable = builtClass.getAnnotation(JsonpDeserializable.class); + if (deserializable == null) { + logf("Could not find @JsonpDeserializable on %s for builder %s", + builtClass, builderSubClass); + // Just ignore and hope this class doesn't matter. + return; + } + + // Technically the name of the constant field may be customized, + // though in practice it's always the default name (_DESERIALIZER). + String fieldName = deserializable.field(); + try { + var field = builtClass.getDeclaredField(fieldName); + logf("Registering deserializer field %s as accessed in %s", fieldName, builtClass); + access.registerAsAccessed(field); + RuntimeReflection.register(field); + } catch (NoSuchFieldException e) { + logf("Could not find deserializer field %s in %s", fieldName, builtClass); + } + } + + private void logf(String message, Object... args) { + if (!log) { + return; + } + System.out.printf("Quarkus's automatic feature for Elasticsearch Java Client: " + message + "\n", args); + } + + @Override + public String getDescription() { + return "Support for Elasticsearch Java Client's withJson() methods in builders"; + } + +} diff --git a/integration-tests/elasticsearch-java-client/src/main/java/io/quarkus/it/elasticsearch/java/FruitResource.java b/integration-tests/elasticsearch-java-client/src/main/java/io/quarkus/it/elasticsearch/java/FruitResource.java index 4063e04644e1e..4d27587777d05 100644 --- a/integration-tests/elasticsearch-java-client/src/main/java/io/quarkus/it/elasticsearch/java/FruitResource.java +++ b/integration-tests/elasticsearch-java-client/src/main/java/io/quarkus/it/elasticsearch/java/FruitResource.java @@ -46,4 +46,12 @@ public List search(@QueryParam("name") String name, @QueryParam("color") } } + // This is just for tests, as it's bad practice to allow REST API callers + // to just inject whatever JSON they like into your Elasticsearch requests. + @GET + @Path("/search/unsafe") + public List searchUnsafe(@QueryParam("json") String json) throws IOException { + return fruitService.searchWithJson(json); + } + } diff --git a/integration-tests/elasticsearch-java-client/src/main/java/io/quarkus/it/elasticsearch/java/FruitService.java b/integration-tests/elasticsearch-java-client/src/main/java/io/quarkus/it/elasticsearch/java/FruitService.java index da2adad9bb686..2c9427e982ece 100644 --- a/integration-tests/elasticsearch-java-client/src/main/java/io/quarkus/it/elasticsearch/java/FruitService.java +++ b/integration-tests/elasticsearch-java-client/src/main/java/io/quarkus/it/elasticsearch/java/FruitService.java @@ -1,6 +1,7 @@ package io.quarkus.it.elasticsearch.java; import java.io.IOException; +import java.io.StringReader; import java.util.List; import java.util.stream.Collectors; @@ -54,4 +55,14 @@ private List search(String term, String match) throws IOException { HitsMetadata hits = searchResponse.hits(); return hits.hits().stream().map(hit -> hit.source()).collect(Collectors.toList()); } + + public List searchWithJson(String json) throws IOException { + SearchRequest searchRequest; + try (var jsonReader = new StringReader(json)) { + searchRequest = SearchRequest.of(b -> b.index("fruits").withJson(jsonReader)); + } + SearchResponse searchResponse = client.search(searchRequest, Fruit.class); + HitsMetadata hits = searchResponse.hits(); + return hits.hits().stream().map(hit -> hit.source()).collect(Collectors.toList()); + } } diff --git a/integration-tests/elasticsearch-java-client/src/test/java/io/quarkus/it/elasticsearch/FruitResourceTest.java b/integration-tests/elasticsearch-java-client/src/test/java/io/quarkus/it/elasticsearch/FruitResourceTest.java index 4bc598c7845d8..e4e0f80ff5217 100644 --- a/integration-tests/elasticsearch-java-client/src/test/java/io/quarkus/it/elasticsearch/FruitResourceTest.java +++ b/integration-tests/elasticsearch-java-client/src/test/java/io/quarkus/it/elasticsearch/FruitResourceTest.java @@ -4,10 +4,12 @@ import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import java.util.List; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import io.quarkus.it.elasticsearch.java.Fruit; @@ -36,27 +38,45 @@ public void testEndpoint() throws InterruptedException { // get the Fruit Fruit result = get("/fruits/1").as(Fruit.class); - Assertions.assertNotNull(result); - Assertions.assertEquals("1", result.id); - Assertions.assertEquals("Apple", result.name); - Assertions.assertEquals("Green", result.color); + assertNotNull(result); + assertEquals("1", result.id); + assertEquals("Apple", result.name); + assertEquals("Green", result.color); // wait a few ms for the indexing to happened Thread.sleep(1000); // search the Fruit List results = get("/fruits/search?color=Green").as(LIST_OF_FRUIT_TYPE_REF); - Assertions.assertNotNull(results); - Assertions.assertFalse(results.isEmpty()); - Assertions.assertEquals("1", results.get(0).id); - Assertions.assertEquals("Apple", results.get(0).name); - Assertions.assertEquals("Green", results.get(0).color); + assertNotNull(results); + assertFalse(results.isEmpty()); + assertEquals("1", results.get(0).id); + assertEquals("Apple", results.get(0).name); + assertEquals("Green", results.get(0).color); + results = get("/fruits/search?name=Apple").as(LIST_OF_FRUIT_TYPE_REF); - Assertions.assertNotNull(results); - Assertions.assertFalse(results.isEmpty()); - Assertions.assertEquals("1", results.get(0).id); - Assertions.assertEquals("Apple", results.get(0).name); - Assertions.assertEquals("Green", results.get(0).color); + assertNotNull(results); + assertFalse(results.isEmpty()); + assertEquals("1", results.get(0).id); + assertEquals("Apple", results.get(0).name); + assertEquals("Green", results.get(0).color); + + results = RestAssured.given().queryParam("json", + "{\n" + + "\"query\": {\n" + + " \"prefix\": {\n" + + " \"name\": {\n" + + " \"value\": \"app\"\n" + + " }\n" + + " }\n" + + "}\n" + + "}\n") + .get("/fruits/search/unsafe").as(LIST_OF_FRUIT_TYPE_REF); + assertNotNull(results); + assertFalse(results.isEmpty()); + assertEquals("1", results.get(0).id); + assertEquals("Apple", results.get(0).name); + assertEquals("Green", results.get(0).color); } @Test