Skip to content

Commit

Permalink
Merge pull request #35138 from Sgitario/35122
Browse files Browse the repository at this point in the history
Allow customizing ObjectReader via `@CustomDeserialization` in Resteasy
  • Loading branch information
Sgitario authored Aug 2, 2023
2 parents fa469e3 + 3cda45d commit a8be00f
Show file tree
Hide file tree
Showing 8 changed files with 280 additions and 28 deletions.
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 })
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

0 comments on commit a8be00f

Please sign in to comment.