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

Allow customizing ObjectReader via @CustomDeserialization in Resteasy #35138

Merged
merged 1 commit into from
Aug 2, 2023
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
33 changes: 29 additions & 4 deletions docs/src/main/asciidoc/resteasy-reactive.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1425,12 +1425,12 @@ public User userPrivate() {
When the result the `userPublic` method is serialized, the `id` field will not be contained in the response as the `Public` view does not include it.
The result of `userPrivate` however will include the `id` as expected when serialized.

===== Completely customized per method serialization
===== Completely customized per method serialization/deserialization

There are times when you need to completely customize the serialization of a POJO on a per Jakarta REST method basis. For such use cases, the `@io.quarkus.resteasy.reactive.jackson.CustomSerialization` annotation
is a great tool, as it allows you to configure a per-method `com.fasterxml.jackson.databind.ObjectWriter` which can be configured at will.
There are times when you need to completely customize the serialization/deserialization of a POJO on a per Jakarta REST method basis. For such use cases, the `@io.quarkus.resteasy.reactive.jackson.CustomSerialization` and `@io.quarkus.resteasy.reactive.jackson.CustomDeserialization` annotations.
is a great tool, as it allows you to configure a per-method `com.fasterxml.jackson.databind.ObjectWriter`/`com.fasterxml.jackson.databind.ObjectReader` which can be configured at will.

Here is an example use case:
Here is an example use case to customize the `com.fasterxml.jackson.databind.ObjectWriter`:

[source,java]
----
Expand Down Expand Up @@ -1459,6 +1459,31 @@ Essentially what this class does is force Jackson to not include quotes in the f

It is important to note that this customization is only performed for the serialization of the Jakarta REST methods that use `@CustomSerialization(UnquotedFields.class)`.

Following the previous example, let's now customize the `com.fasterxml.jackson.databind.ObjectReader` to read JSON requests with unquoted field names:

[source,java]
----
@CustomDeserialization(SupportUnquotedFields.class)
@POST
@Path("/use-of-custom-deserializer")
public void useOfCustomSerializer(User request) {
// ...
}
----

where `SupportUnquotedFields` is a `BiFunction` defined as so:

[source,java]
----
public static class SupportUnquotedFields implements BiFunction<ObjectMapper, Type, ObjectReader> {

@Override
public ObjectReader apply(ObjectMapper objectMapper, Type type) {
return objectMapper.reader().with(JsonReadFeature.ALLOW_UNQUOTED_FIELD_NAMES);
}
}
----

=== XML serialisation

[[xml]]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public Feature getFeature() {

public enum Feature {
JSON_VIEW,
CUSTOM_SERIALIZATION
CUSTOM_SERIALIZATION,
CUSTOM_DESERIALIZATION
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
import io.quarkus.resteasy.reactive.common.deployment.JaxRsResourceIndexBuildItem;
import io.quarkus.resteasy.reactive.common.deployment.ResourceScanningResultBuildItem;
import io.quarkus.resteasy.reactive.common.deployment.ServerDefaultProducesHandlerBuildItem;
import io.quarkus.resteasy.reactive.jackson.CustomDeserialization;
import io.quarkus.resteasy.reactive.jackson.CustomSerialization;
import io.quarkus.resteasy.reactive.jackson.DisableSecureSerialization;
import io.quarkus.resteasy.reactive.jackson.EnableSecureSerialization;
Expand Down Expand Up @@ -77,6 +78,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());
private static final DotName CUSTOM_DESERIALIZATION = DotName.createSimple(CustomDeserialization.class.getName());
private static final DotName SECURE_FIELD = DotName.createSimple(SecureField.class.getName());
private static final DotName DISABLE_SECURE_SERIALIZATION = DotName
.createSimple(DisableSecureSerialization.class.getName());
Expand Down Expand Up @@ -265,6 +267,36 @@ void handleJsonAnnotations(Optional<ResourceScanningResultBuildItem> resourceSca
biFunctionType.name().toString());
}
}
if (resourceClass.annotationsMap().containsKey(CUSTOM_DESERIALIZATION)) {
jacksonFeatures.add(JacksonFeatureBuildItem.Feature.CUSTOM_DESERIALIZATION);
for (AnnotationInstance instance : resourceClass.annotationsMap().get(CUSTOM_DESERIALIZATION)) {
AnnotationValue annotationValue = instance.value();
if (annotationValue == null) {
continue;
}
if (instance.target().kind() != AnnotationTarget.Kind.METHOD) {
continue;
}
Type biFunctionType = annotationValue.asClass();
if (biFunctionType == null) {
continue;
}
ClassInfo biFunctionClassInfo = index.getIndex().getClassByName(biFunctionType.name());
if (biFunctionClassInfo == null) {
// be lenient
} else {
if (!biFunctionClassInfo.hasNoArgsConstructor()) {
throw new IllegalArgumentException(
"Class '" + biFunctionClassInfo.name() + "' must contain a no-args constructor");
}
}
reflectiveClassProducer.produce(
ReflectiveClassBuildItem.builder(biFunctionType.name().toString())
.build());
recorder.recordCustomDeserialization(getMethodId(instance.target().asMethod()),
biFunctionType.name().toString());
}
}
}

for (ResourceMethodCustomSerializationBuildItem bi : resourceMethodCustomSerializationBuildItems) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@
import org.jboss.resteasy.reactive.server.ServerExceptionMapper;

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

import io.quarkus.resteasy.reactive.jackson.CustomDeserialization;
import io.quarkus.resteasy.reactive.jackson.CustomSerialization;
import io.quarkus.resteasy.reactive.jackson.DisableSecureSerialization;
import io.quarkus.resteasy.reactive.jackson.EnableSecureSerialization;
Expand Down Expand Up @@ -56,13 +59,20 @@ public Person getPerson() {
return person;
}

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

@CustomDeserialization(UnquotedFieldsPersonDeserialization.class)
@POST
@Path("custom-deserialized-person")
public Person echoCustomDeserializedPerson(Person request) {
return request;
}

@EnableSecureSerialization
@GET
@Path("secure-person")
Expand Down Expand Up @@ -180,7 +190,8 @@ public List<Person> getPeople(List<Person> people) {
return reversed;
}

@CustomSerialization(UnquotedFieldsPersonBiFunction.class)
@CustomDeserialization(UnquotedFieldsPersonDeserialization.class)
@CustomSerialization(UnquotedFieldsPersonSerialization.class)
@POST
@Path("/custom-serialized-people")
@Consumes(MediaType.APPLICATION_JSON)
Expand Down Expand Up @@ -261,7 +272,7 @@ public User userWithPrivateView() {
return testUser();
}

@CustomSerialization(UnquotedFieldsPersonBiFunction.class)
@CustomSerialization(UnquotedFieldsPersonSerialization.class)
@GET
@Path("/invalid-use-of-custom-serializer")
public User invalidUseOfCustomSerializer() {
Expand Down Expand Up @@ -308,11 +319,11 @@ public String genericInputTest(DataItem<Item> item) {
return item.getContent().getName();
}

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

public static final AtomicInteger count = new AtomicInteger();

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

Expand All @@ -328,4 +339,24 @@ public ObjectWriter apply(ObjectMapper objectMapper, Type type) {
}
}

public static class UnquotedFieldsPersonDeserialization implements BiFunction<ObjectMapper, Type, ObjectReader> {

public static final AtomicInteger count = new AtomicInteger();

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

@Override
public ObjectReader 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.reader().with(JsonReadFeature.ALLOW_UNQUOTED_FIELD_NAMES);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ public void testJsonMulti() {

@Test
public void testCustomSerialization() {
assertEquals(0, SimpleJsonResource.UnquotedFieldsPersonBiFunction.count.intValue());
assertEquals(0, SimpleJsonResource.UnquotedFieldsPersonSerialization.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
Expand All @@ -261,7 +261,7 @@ public void testCustomSerialization() {
.body(containsString("Bob"))
.body(containsString("Builder"));
// assert that our bi-function was created
assertEquals(1, SimpleJsonResource.UnquotedFieldsPersonBiFunction.count.intValue());
assertEquals(1, SimpleJsonResource.UnquotedFieldsPersonSerialization.count.intValue());

// assert with a list of people
RestAssured
Expand All @@ -279,7 +279,7 @@ public void testCustomSerialization() {
.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());
assertEquals(2, SimpleJsonResource.UnquotedFieldsPersonSerialization.count.intValue());

RestAssured.get("/simple/custom-serialized-person")
.then()
Expand All @@ -294,13 +294,59 @@ public void testCustomSerialization() {
.statusCode(200)
.contentType("application/json");
// assert that the instances were re-used as we simply invoked methods that should have already created their object writers
assertEquals(2, SimpleJsonResource.UnquotedFieldsPersonBiFunction.count.intValue());
assertEquals(2, SimpleJsonResource.UnquotedFieldsPersonSerialization.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());
assertEquals(3, SimpleJsonResource.UnquotedFieldsPersonSerialization.count.intValue());
}

@Test
public void testCustomDeserialization() {
int currentCounter = SimpleJsonResource.UnquotedFieldsPersonDeserialization.count.intValue();

// assert that the reader support the unquoted fields (because we have used a custom object reader
// via `@CustomDeserialization`
Person actual = RestAssured.given()
.body("{first: \"Hello\", last: \"Deserialization\"}")
.contentType("application/json; charset=utf-8")
.post("/simple/custom-deserialized-person")
.then()
.statusCode(200)
.contentType("application/json")
.header("transfer-encoding", nullValue())
.header("content-length", notNullValue())
.extract().as(Person.class);
assertEquals("Hello", actual.getFirst());
assertEquals("Deserialization", actual.getLast());
assertEquals(currentCounter + 1, SimpleJsonResource.UnquotedFieldsPersonDeserialization.count.intValue());

// assert that the instances were re-used as we simply invoked methods that should have already created their object readers
RestAssured.given()
.body("{first: \"Hello\", last: \"Deserialization\"}")
.contentType("application/json; charset=utf-8")
.post("/simple/custom-deserialized-person")
.then()
.statusCode(200);
assertEquals(currentCounter + 1, SimpleJsonResource.UnquotedFieldsPersonDeserialization.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")
.header("transfer-encoding", nullValue())
.header("content-length", notNullValue())
.body(containsString("Bob"))
.body(containsString("Builder"))
.body(containsString("Bob2"))
.body(containsString("Builder2"));
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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.ObjectReader;

import io.smallrye.common.annotation.Experimental;

/**
* Annotation that can be used on RESTEasy Reactive Resource method to allow users to configure Jackson deserialization
* 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 Deserialization")
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.METHOD })
geoand marked this conversation as resolved.
Show resolved Hide resolved
public @interface CustomDeserialization {

/**
* A {@code BiFunction} that converts the global {@code ObjectMapper} and type for which a custom {@code ObjectReader} 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 ObjectReader}.
* <p>
* Quarkus will construct one instance of this {@code BiFunction} for each JAX-RS resource method that is annotated with
* {@code CustomDeserialization} and once an instance is created it will be cached for subsequent usage by that resource
* method.
* <p>
* The {@code BiFunction} MUST contain a no-args constructor.
* <p>
* Furthermore, it is advisable that it contains no state that is updated outside
* its constructor.
* <p>
* Finally and most importantly, 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, ObjectReader>> value();
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.util.function.BiFunction;

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

import io.quarkus.runtime.ShutdownContext;
Expand All @@ -16,6 +17,7 @@ public class ResteasyReactiveServerJacksonRecorder {

private static final Map<String, Class<?>> jsonViewMap = new HashMap<>();
private static final Map<String, Class<?>> customSerializationMap = new HashMap<>();
private static final Map<String, Class<?>> customDeserializationMap = new HashMap<>();

public void recordJsonView(String methodId, String className) {
jsonViewMap.put(methodId, loadClass(className));
Expand All @@ -25,12 +27,17 @@ public void recordCustomSerialization(String methodId, String className) {
customSerializationMap.put(methodId, loadClass(className));
}

public void recordCustomDeserialization(String methodId, String className) {
customDeserializationMap.put(methodId, loadClass(className));
}

public void configureShutdown(ShutdownContext shutdownContext) {
shutdownContext.addShutdownTask(new Runnable() {
@Override
public void run() {
jsonViewMap.clear();
customSerializationMap.clear();
customDeserializationMap.clear();
}
});
}
Expand All @@ -44,6 +51,12 @@ public static Class<? extends BiFunction<ObjectMapper, Type, ObjectWriter>> cust
return (Class<? extends BiFunction<ObjectMapper, Type, ObjectWriter>>) customSerializationMap.get(methodId);
}

@SuppressWarnings("unchecked")
public static Class<? extends BiFunction<ObjectMapper, Type, ObjectReader>> customDeserializationForMethod(
String methodId) {
return (Class<? extends BiFunction<ObjectMapper, Type, ObjectReader>>) customDeserializationMap.get(methodId);
}

private Class<?> loadClass(String className) {
try {
return Thread.currentThread().getContextClassLoader().loadClass(className);
Expand Down
Loading