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

Introduce declarative support for Custom Jackson Serialization #14223

Merged
merged 1 commit into from
Jan 12, 2021
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
Expand Up @@ -8,19 +8,24 @@

import javax.ws.rs.core.MediaType;

import org.jboss.jandex.AnnotationInstance;
import org.jboss.jandex.AnnotationValue;
import org.jboss.jandex.ClassInfo;
import org.jboss.jandex.DotName;
import org.jboss.jandex.Type;

import com.fasterxml.jackson.annotation.JsonView;

import io.quarkus.arc.deployment.AdditionalBeanBuildItem;
import io.quarkus.deployment.Feature;
import io.quarkus.deployment.annotations.BuildProducer;
import io.quarkus.deployment.annotations.BuildStep;
import io.quarkus.deployment.builditem.CombinedIndexBuildItem;
import io.quarkus.deployment.builditem.FeatureBuildItem;
import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem;
import io.quarkus.resteasy.reactive.common.deployment.ResourceScanningResultBuildItem;
import io.quarkus.resteasy.reactive.common.deployment.ServerDefaultProducesHandlerBuildItem;
import io.quarkus.resteasy.reactive.jackson.CustomSerialization;
import io.quarkus.resteasy.reactive.jackson.runtime.serialisers.JacksonMessageBodyReader;
import io.quarkus.resteasy.reactive.jackson.runtime.serialisers.JacksonMessageBodyWriter;
import io.quarkus.resteasy.reactive.spi.MessageBodyReaderBuildItem;
Expand All @@ -29,6 +34,7 @@
public class ResteasyReactiveJacksonProcessor {

private static final DotName JSON_VIEW = DotName.createSimple(JsonView.class.getName());
private static final DotName CUSTOM_SERIALIZATION = DotName.createSimple(CustomSerialization.class.getName());

@BuildStep
void feature(BuildProducer<FeatureBuildItem> feature) {
Expand Down Expand Up @@ -59,7 +65,8 @@ void additionalProviders(BuildProducer<AdditionalBeanBuildItem> additionalBean,
}

@BuildStep
void registerForReflection(Optional<ResourceScanningResultBuildItem> resourceScanningResultBuildItem,
void handleJsonAnnotations(Optional<ResourceScanningResultBuildItem> resourceScanningResultBuildItem,
CombinedIndexBuildItem index,
BuildProducer<ReflectiveClassBuildItem> reflectiveClass) {
if (!resourceScanningResultBuildItem.isPresent()) {
return;
Expand All @@ -68,8 +75,28 @@ void registerForReflection(Optional<ResourceScanningResultBuildItem> resourceSca
.values();
Set<String> classesNeedingReflectionOnMethods = new HashSet<>();
for (ClassInfo resourceClass : resourceClasses) {
DotName resourceClassDotName = resourceClass.name();
if (resourceClass.annotations().containsKey(JSON_VIEW)) {
classesNeedingReflectionOnMethods.add(resourceClass.name().toString());
classesNeedingReflectionOnMethods.add(resourceClassDotName.toString());
} else if (resourceClass.annotations().containsKey(CUSTOM_SERIALIZATION)) {
classesNeedingReflectionOnMethods.add(resourceClassDotName.toString());
for (AnnotationInstance instance : resourceClass.annotations().get(CUSTOM_SERIALIZATION)) {
AnnotationValue annotationValue = instance.value();
if (annotationValue != null) {
Type biFunctionType = annotationValue.asClass();
ClassInfo biFunctionClassInfo = index.getIndex().getClassByName(biFunctionType.name());
if (biFunctionClassInfo == null) {
// be lenient
} else {
if (!biFunctionClassInfo.hasNoArgsConstructor()) {
throw new RuntimeException(
"Class '" + biFunctionClassInfo.name() + "' must contain a no-args constructor");
}
}
reflectiveClass.produce(
new ReflectiveClassBuildItem(true, false, false, biFunctionType.name().toString()));
}
}
}
}
if (!classesNeedingReflectionOnMethods.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package io.quarkus.resteasy.reactive.jackson.deployment.test;

import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BiFunction;

import javax.validation.Valid;
import javax.ws.rs.Consumes;
Expand All @@ -18,7 +22,11 @@

import com.fasterxml.jackson.annotation.JsonView;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.json.JsonWriteFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;

import io.quarkus.resteasy.reactive.jackson.CustomSerialization;
import io.quarkus.runtime.BlockingOperationControl;
import io.smallrye.mutiny.Multi;

Expand All @@ -39,6 +47,13 @@ public Person getPerson() {
return person;
}

@CustomSerialization(UnquotedFieldsPersonBiFunction.class)
@GET
@Path("custom-serialized-person")
public Person getCustomSerializedPerson() {
return getPerson();
}

@POST
@Path("/person")
@Produces(MediaType.APPLICATION_JSON)
Expand Down Expand Up @@ -97,6 +112,14 @@ public List<Person> getPeople(List<Person> people) {
return reversed;
}

@CustomSerialization(UnquotedFieldsPersonBiFunction.class)
@POST
@Path("/custom-serialized-people")
@Consumes(MediaType.APPLICATION_JSON)
public List<Person> getCustomSerializedPeople(List<Person> people) {
return getPeople(people);
}

@POST
@Path("/strings")
public List<String> strings(List<String> strings) {
Expand Down Expand Up @@ -171,6 +194,13 @@ public User userWithPrivateView() {
return testUser();
}

@CustomSerialization(UnquotedFieldsPersonBiFunction.class)
@GET
@Path("/invalid-use-of-custom-serializer")
public User invalidUseOfCustomSerializer() {
return testUser();
}

private User testUser() {
User user = new User();
user.id = 1;
Expand Down Expand Up @@ -204,4 +234,24 @@ public Multi<Person> getMulti2() {
public Multi<Person> getMulti0() {
return Multi.createFrom().empty();
}

public static class UnquotedFieldsPersonBiFunction implements BiFunction<ObjectMapper, Type, ObjectWriter> {

public static final AtomicInteger count = new AtomicInteger();

public UnquotedFieldsPersonBiFunction() {
count.incrementAndGet();
}

@Override
public ObjectWriter apply(ObjectMapper objectMapper, Type type) {
if (type instanceof ParameterizedType) {
type = ((ParameterizedType) type).getActualTypeArguments()[0];
}
if (!type.getTypeName().equals(Person.class.getName())) {
throw new IllegalArgumentException("Only Person type can be handled");
}
return objectMapper.writer().without(JsonWriteFeature.QUOTE_FIELD_NAMES);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.function.Supplier;

Expand Down Expand Up @@ -228,4 +229,57 @@ public void testJsonMulti() {
.contentType("application/json")
.body(Matchers.equalTo("[]"));
}

@Test
public void testCustomSerialization() {
assertEquals(0, SimpleJsonResource.UnquotedFieldsPersonBiFunction.count.intValue());

// assert that we get a proper response
// we can't use json-path to assert because the returned string is not proper json as it does not have quotes around the field names
RestAssured.get("/simple/custom-serialized-person")
.then()
.statusCode(200)
.contentType("application/json")
.body(containsString("Bob"))
.body(containsString("Builder"));
// assert that our bi-function was created
assertEquals(1, SimpleJsonResource.UnquotedFieldsPersonBiFunction.count.intValue());

// assert with a list of people
RestAssured
.with()
.body("[{\"first\": \"Bob\", \"last\": \"Builder\"}, {\"first\": \"Bob2\", \"last\": \"Builder2\"}]")
.contentType("application/json; charset=utf-8")
.post("/simple/custom-serialized-people")
.then()
.statusCode(200)
.contentType("application/json")
.body(containsString("Bob"))
.body(containsString("Builder"))
.body(containsString("Bob2"))
.body(containsString("Builder2"));
// assert that another instance of our bi-function was created as a different resource method was used
assertEquals(2, SimpleJsonResource.UnquotedFieldsPersonBiFunction.count.intValue());

RestAssured.get("/simple/custom-serialized-person")
.then()
.statusCode(200)
.contentType("application/json");
RestAssured
.with()
.body("[{\"first\": \"Bob\", \"last\": \"Builder\"}, {\"first\": \"Bob2\", \"last\": \"Builder2\"}]")
.contentType("application/json; charset=utf-8")
.post("/simple/custom-serialized-people")
.then()
.statusCode(200)
.contentType("application/json");
// assert that the instances were re-used as we simply invoked methods that should have already created their object writters
assertEquals(2, SimpleJsonResource.UnquotedFieldsPersonBiFunction.count.intValue());

RestAssured.get("/simple/invalid-use-of-custom-serializer")
.then()
.statusCode(500);
// a new instance should have been created
assertEquals(3, SimpleJsonResource.UnquotedFieldsPersonBiFunction.count.intValue());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package io.quarkus.resteasy.reactive.jackson;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Type;
import java.util.function.BiFunction;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;

import io.smallrye.common.annotation.Experimental;

/**
* Annotation that can be used on RESTEasy Reactive Resource method to allow users to configure Jackson serialization
* for that method only, without affecting the global Jackson configuration.
*/
@Experimental(value = "Remains to be determined if this is the best possible API for users to configure per Resource Method Serialization")
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.METHOD })
public @interface CustomSerialization {

/**
* A {@code BiFunction} that converts the global {@code ObjectMapper} and type for which a custom {@code ObjectWriter} is
* needed
* (this type will be a generic type if the method returns such a generic type) and returns the instance of the custom
* {@code ObjectWriter}.
*
* Quarkus will construct one instance of this {@code BiFunction} for each JAX-RS resource method that is annotated with
* {@code CustomSerialization} and once an instance is created it will be cached for subsequent usage by that resource
* method.
*
* The class MUST contain a no-args constructor and it is advisable that it contains no state that is updated outside
* of its constructor.
* Furthermore, the {@code ObjectMapper} should NEVER be changed any way as it is the global ObjectMapper that is
* accessible to the entire Quarkus application.
*/
Class<? extends BiFunction<ObjectMapper, Type, ObjectWriter>> value();
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.lang.reflect.Type;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.BiFunction;
import java.util.function.Function;

import javax.inject.Inject;
import javax.ws.rs.WebApplicationException;
Expand All @@ -23,20 +28,27 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;

import io.quarkus.resteasy.reactive.jackson.CustomSerialization;

public class JacksonMessageBodyWriter implements ServerMessageBodyWriter<Object> {

private static final String JSON_VIEW_NAME = JsonView.class.getName();
private final ObjectWriter writer;
private static final String CUSTOM_SERIALIZATION = CustomSerialization.class.getName();

private final ObjectMapper originalMapper;
private final ObjectWriter defaultWriter;
private final ConcurrentMap<Method, ObjectWriter> perMethodWriter = new ConcurrentHashMap<>();

@Inject
public JacksonMessageBodyWriter(ObjectMapper mapper) {
this.originalMapper = mapper;
// we don't want the ObjectWriter to close the stream automatically, as we want to handle closing manually at the proper points
if (mapper.getFactory().isEnabled(JsonGenerator.Feature.AUTO_CLOSE_TARGET)) {
JsonFactory jsonFactory = mapper.getFactory().copy();
jsonFactory.configure(JsonGenerator.Feature.AUTO_CLOSE_TARGET, false);
this.writer = mapper.writer().with(jsonFactory);
this.defaultWriter = mapper.writer().with(jsonFactory);
} else {
this.writer = mapper.writer();
this.defaultWriter = mapper.writer();
}
}

Expand All @@ -61,7 +73,7 @@ public void writeTo(Object o, Class<?> type, Type genericType, Annotation[] anno
}
}
}
entityStream.write(writer.writeValueAsBytes(o));
entityStream.write(defaultWriter.writeValueAsBytes(o));
}
}

Expand All @@ -81,23 +93,69 @@ public void writeResponse(Object o, Type genericType, ServerRequestContext conte
// First test the names to see if JsonView is used. We do this to avoid doing reflection for the common case
// where JsonView is not used
ResteasyReactiveResourceInfo resourceInfo = context.getResteasyReactiveResourceInfo();
if ((resourceInfo != null) && resourceInfo.getMethodAnnotationNames().contains(JSON_VIEW_NAME)) {
Method method = resourceInfo.getMethod();
if (handleJsonView(method.getAnnotation(JsonView.class), o, stream)) {
return;
if (resourceInfo != null) {
Set<String> methodAnnotationNames = resourceInfo.getMethodAnnotationNames();
if (methodAnnotationNames.contains(CUSTOM_SERIALIZATION)) {
Method method = resourceInfo.getMethod();
if (handleCustomSerialization(method, o, genericType, stream)) {
return;
}
} else if (methodAnnotationNames.contains(JSON_VIEW_NAME)) {
Method method = resourceInfo.getMethod();
if (handleJsonView(method.getAnnotation(JsonView.class), o, stream)) {
return;
}
}
}
writer.writeValue(stream, o);
defaultWriter.writeValue(stream, o);
}
// we don't use try-with-resources because that results in writing to the http output without the exception mapping coming into play
stream.close();
}

// TODO: this can definitely be made faster if necessary by optimizing the use of the map and also by moving the creation of the
// biFunction to build time
private boolean handleCustomSerialization(Method method, Object o, Type genericType, OutputStream stream)
throws IOException {
CustomSerialization customSerialization = method.getAnnotation(CustomSerialization.class);
if ((customSerialization == null)) {
return false;
}
Class<? extends BiFunction<ObjectMapper, Type, ObjectWriter>> biFunctionClass = customSerialization.value();
ObjectWriter objectWriter = perMethodWriter.computeIfAbsent(method,
new MethodObjectWriterFunction(biFunctionClass, genericType, originalMapper));
objectWriter.writeValue(stream, o);
return true;
}

private boolean handleJsonView(JsonView jsonView, Object o, OutputStream stream) throws IOException {
if ((jsonView != null) && (jsonView.value().length > 0)) {
writer.withView(jsonView.value()[0]).writeValue(stream, o);
defaultWriter.withView(jsonView.value()[0]).writeValue(stream, o);
return true;
}
return false;
}

private static class MethodObjectWriterFunction implements Function<Method, ObjectWriter> {
private final Class<? extends BiFunction<ObjectMapper, Type, ObjectWriter>> clazz;
private final Type genericType;
private final ObjectMapper originalMapper;

public MethodObjectWriterFunction(Class<? extends BiFunction<ObjectMapper, Type, ObjectWriter>> clazz, Type genericType,
ObjectMapper originalMapper) {
this.clazz = clazz;
this.genericType = genericType;
this.originalMapper = originalMapper;
}

@Override
public ObjectWriter apply(Method method) {
try {
BiFunction<ObjectMapper, Type, ObjectWriter> biFunctionInstance = clazz.getDeclaredConstructor().newInstance();
return biFunctionInstance.apply(originalMapper, genericType);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
}