diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/deployment/src/main/java/io/quarkus/resteasy/reactive/jackson/deployment/processor/ResteasyReactiveJacksonProcessor.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/deployment/src/main/java/io/quarkus/resteasy/reactive/jackson/deployment/processor/ResteasyReactiveJacksonProcessor.java index 5e8c86c6db0cd..04b060481f1a2 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/deployment/src/main/java/io/quarkus/resteasy/reactive/jackson/deployment/processor/ResteasyReactiveJacksonProcessor.java +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/deployment/src/main/java/io/quarkus/resteasy/reactive/jackson/deployment/processor/ResteasyReactiveJacksonProcessor.java @@ -8,8 +8,11 @@ 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; @@ -17,10 +20,12 @@ 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; @@ -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 feature) { @@ -59,7 +65,8 @@ void additionalProviders(BuildProducer additionalBean, } @BuildStep - void registerForReflection(Optional resourceScanningResultBuildItem, + void handleJsonAnnotations(Optional resourceScanningResultBuildItem, + CombinedIndexBuildItem index, BuildProducer reflectiveClass) { if (!resourceScanningResultBuildItem.isPresent()) { return; @@ -68,8 +75,28 @@ void registerForReflection(Optional resourceSca .values(); Set 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()) { diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/SimpleJsonResource.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/SimpleJsonResource.java index 245b9ae55463b..9df7219b1292a 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/SimpleJsonResource.java +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/SimpleJsonResource.java @@ -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; @@ -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; @@ -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) @@ -97,6 +112,14 @@ public List getPeople(List people) { return reversed; } + @CustomSerialization(UnquotedFieldsPersonBiFunction.class) + @POST + @Path("/custom-serialized-people") + @Consumes(MediaType.APPLICATION_JSON) + public List getCustomSerializedPeople(List people) { + return getPeople(people); + } + @POST @Path("/strings") public List strings(List strings) { @@ -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; @@ -204,4 +234,24 @@ public Multi getMulti2() { public Multi getMulti0() { return Multi.createFrom().empty(); } + + public static class UnquotedFieldsPersonBiFunction implements BiFunction { + + 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); + } + } } diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/SimpleJsonTest.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/SimpleJsonTest.java index bb61a9097534a..d33c9f714890b 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/SimpleJsonTest.java +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/SimpleJsonTest.java @@ -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; @@ -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()); + } } diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/runtime/src/main/java/io/quarkus/resteasy/reactive/jackson/CustomSerialization.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/runtime/src/main/java/io/quarkus/resteasy/reactive/jackson/CustomSerialization.java new file mode 100644 index 0000000000000..db8aa8243a9af --- /dev/null +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/runtime/src/main/java/io/quarkus/resteasy/reactive/jackson/CustomSerialization.java @@ -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> value(); +} diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/runtime/src/main/java/io/quarkus/resteasy/reactive/jackson/runtime/serialisers/JacksonMessageBodyWriter.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/runtime/src/main/java/io/quarkus/resteasy/reactive/jackson/runtime/serialisers/JacksonMessageBodyWriter.java index 2478816de3e48..7fce5554bd8fb 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/runtime/src/main/java/io/quarkus/resteasy/reactive/jackson/runtime/serialisers/JacksonMessageBodyWriter.java +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/runtime/src/main/java/io/quarkus/resteasy/reactive/jackson/runtime/serialisers/JacksonMessageBodyWriter.java @@ -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; @@ -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 { 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 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(); } } @@ -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)); } } @@ -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 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> 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 { + private final Class> clazz; + private final Type genericType; + private final ObjectMapper originalMapper; + + public MethodObjectWriterFunction(Class> clazz, Type genericType, + ObjectMapper originalMapper) { + this.clazz = clazz; + this.genericType = genericType; + this.originalMapper = originalMapper; + } + + @Override + public ObjectWriter apply(Method method) { + try { + BiFunction biFunctionInstance = clazz.getDeclaredConstructor().newInstance(); + return biFunctionInstance.apply(originalMapper, genericType); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } }