Skip to content

Commit

Permalink
Make Elasticsearch Java Client's withJson(...) methods work in native…
Browse files Browse the repository at this point in the history
… mode
  • Loading branch information
yrodiere committed May 31, 2023
1 parent 9aaa021 commit 7c14d42
Show file tree
Hide file tree
Showing 5 changed files with 176 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
}

}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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";
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,12 @@ public List<Fruit> 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<Fruit> searchUnsafe(@QueryParam("json") String json) throws IOException {
return fruitService.searchWithJson(json);
}

}
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -54,4 +55,14 @@ private List<Fruit> search(String term, String match) throws IOException {
HitsMetadata<Fruit> hits = searchResponse.hits();
return hits.hits().stream().map(hit -> hit.source()).collect(Collectors.toList());
}

public List<Fruit> searchWithJson(String json) throws IOException {
SearchRequest searchRequest;
try (var jsonReader = new StringReader(json)) {
searchRequest = SearchRequest.of(b -> b.index("fruits").withJson(jsonReader));
}
SearchResponse<Fruit> searchResponse = client.search(searchRequest, Fruit.class);
HitsMetadata<Fruit> hits = searchResponse.hits();
return hits.hits().stream().map(hit -> hit.source()).collect(Collectors.toList());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Fruit> 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
Expand Down

0 comments on commit 7c14d42

Please sign in to comment.