Skip to content

Commit

Permalink
Merge pull request #14223 from geoand/rr-jackson-custom-serializer
Browse files Browse the repository at this point in the history
Introduce declarative support for Custom Jackson Serialization
  • Loading branch information
geoand authored Jan 12, 2021
2 parents 1268f72 + ee1bd1a commit 96d411e
Show file tree
Hide file tree
Showing 5 changed files with 241 additions and 12 deletions.
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);
}
}
}
}

0 comments on commit 96d411e

Please sign in to comment.