Skip to content

Commit

Permalink
Merge pull request #35158 from Sgitario/35138_followup
Browse files Browse the repository at this point in the history
Allow using `@CustomSerialization` and `@CustomDeserialization` at class level
  • Loading branch information
Sgitario authored Aug 3, 2023
2 parents 97330b9 + 4ee0afe commit 74cf787
Show file tree
Hide file tree
Showing 9 changed files with 329 additions and 54 deletions.
3 changes: 1 addition & 2 deletions docs/src/main/asciidoc/resteasy-reactive.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1427,8 +1427,7 @@ The result of `userPrivate` however will include the `id` as expected when seria

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

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.
There are times when you need to completely customize the serialization/deserialization of a POJO on a per Jakarta REST method basis or on a per Jakarta REST resource basis. For such use cases, you can use the `@io.quarkus.resteasy.reactive.jackson.CustomSerialization` and `@io.quarkus.resteasy.reactive.jackson.CustomDeserialization` annotations in the REST method or in the REST resource at class level. These annotations allow you to fully configure the `com.fasterxml.jackson.databind.ObjectWriter`/`com.fasterxml.jackson.databind.ObjectReader`.

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,6 @@ void handleJsonAnnotations(Optional<ResourceScanningResultBuildItem> resourceSca
if (annotationValue == null) {
continue;
}
if (instance.target().kind() != AnnotationTarget.Kind.METHOD) {
continue;
}
Type[] jsonViews = annotationValue.asClassArray();
if ((jsonViews == null) || (jsonViews.length == 0)) {
continue;
Expand All @@ -244,9 +241,6 @@ void handleJsonAnnotations(Optional<ResourceScanningResultBuildItem> resourceSca
if (annotationValue == null) {
continue;
}
if (instance.target().kind() != AnnotationTarget.Kind.METHOD) {
continue;
}
Type biFunctionType = annotationValue.asClass();
if (biFunctionType == null) {
continue;
Expand All @@ -263,8 +257,7 @@ void handleJsonAnnotations(Optional<ResourceScanningResultBuildItem> resourceSca
reflectiveClassProducer.produce(
ReflectiveClassBuildItem.builder(biFunctionType.name().toString())
.build());
recorder.recordCustomSerialization(getMethodId(instance.target().asMethod()),
biFunctionType.name().toString());
recorder.recordCustomSerialization(getTargetId(instance.target()), biFunctionType.name().toString());
}
}
if (resourceClass.annotationsMap().containsKey(CUSTOM_DESERIALIZATION)) {
Expand All @@ -274,9 +267,6 @@ void handleJsonAnnotations(Optional<ResourceScanningResultBuildItem> resourceSca
if (annotationValue == null) {
continue;
}
if (instance.target().kind() != AnnotationTarget.Kind.METHOD) {
continue;
}
Type biFunctionType = annotationValue.asClass();
if (biFunctionType == null) {
continue;
Expand All @@ -293,8 +283,7 @@ void handleJsonAnnotations(Optional<ResourceScanningResultBuildItem> resourceSca
reflectiveClassProducer.produce(
ReflectiveClassBuildItem.builder(biFunctionType.name().toString())
.build());
recorder.recordCustomDeserialization(getMethodId(instance.target().asMethod()),
biFunctionType.name().toString());
recorder.recordCustomDeserialization(getTargetId(instance.target()), biFunctionType.name().toString());
}
}
}
Expand Down Expand Up @@ -391,10 +380,12 @@ public void handleFieldSecurity(ResteasyReactiveResourceMethodEntriesBuildItem r
}
}
if (hasSecureFields) {
AnnotationInstance customSerializationAnnotation = methodInfo.annotation(CUSTOM_SERIALIZATION);
if (customSerializationAnnotation != null) {
AnnotationInstance customSerializationAtClassAnnotation = methodInfo.declaringClass()
.declaredAnnotation(CUSTOM_SERIALIZATION);
AnnotationInstance customSerializationAtMethodAnnotation = methodInfo.annotation(CUSTOM_SERIALIZATION);
if (customSerializationAtMethodAnnotation != null || customSerializationAtClassAnnotation != null) {
log.warn("Secure serialization will not be applied to method: '" + methodInfo.declaringClass().name() + "#"
+ methodInfo.name() + "' because it is annotated with @CustomSerialization.");
+ methodInfo.name() + "' because the method or class are annotated with @CustomSerialization.");
} else {
result.add(new ResourceMethodCustomSerializationBuildItem(methodInfo, entry.getActualClassInfo(),
SecurityCustomSerialization.class));
Expand All @@ -408,6 +399,21 @@ public void handleFieldSecurity(ResteasyReactiveResourceMethodEntriesBuildItem r
}
}

private String getTargetId(AnnotationTarget target) {
if (target.kind() == AnnotationTarget.Kind.CLASS) {
return getClassId(target.asClass());
} else if (target.kind() == AnnotationTarget.Kind.METHOD) {
return getMethodId(target.asMethod());
}

throw new UnsupportedOperationException("The `@CustomSerialization` and `@CustomDeserialization` annotations can only "
+ "be used in methods or classes.");
}

private String getClassId(ClassInfo classInfo) {
return classInfo.name().toString();
}

private String getMethodId(MethodInfo methodInfo) {
return getMethodId(methodInfo, methodInfo.declaringClass());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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 jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;

import org.jboss.resteasy.reactive.server.ServerExceptionMapper;

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;

@Path("/custom-serialization")
@CustomSerialization(CustomSerializationResource.UnquotedFieldsPersonSerialization.class)
@CustomDeserialization(CustomSerializationResource.UnquotedFieldsPersonDeserialization.class)
public class CustomSerializationResource {

@ServerExceptionMapper
public Response handleParseException(WebApplicationException e) {
var cause = e.getCause() == null ? e : e.getCause();
return Response.status(Response.Status.BAD_REQUEST).entity(cause.getMessage()).build();
}

@GET
@Path("/person")
public Person getPerson() {
Person person = new Person();
person.setFirst("Bob");
person.setLast("Builder");
return person;
}

@POST
@Path("/person")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public Person getPerson(Person person) {
return person;
}

@POST
@Path("/people/list")
@Consumes(MediaType.APPLICATION_JSON)
public List<Person> getPeople(List<Person> people) {
List<Person> reversed = new ArrayList<>(people.size());
for (Person person : people) {
reversed.add(0, person);
}
return reversed;
}

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

private User testUser() {
User user = new User();
user.id = 1;
user.name = "test";
return user;
}

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

public static final AtomicInteger count = new AtomicInteger();

public UnquotedFieldsPersonSerialization() {
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);
}
}

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
@@ -0,0 +1,97 @@
package io.quarkus.resteasy.reactive.jackson.deployment.test;

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

import java.util.function.Supplier;

import org.jboss.shrinkwrap.api.ShrinkWrap;
import org.jboss.shrinkwrap.api.spec.JavaArchive;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.test.QuarkusUnitTest;
import io.restassured.RestAssured;

public class CustomSerializationTest {

@RegisterExtension
static QuarkusUnitTest test = new QuarkusUnitTest()
.setArchiveProducer(new Supplier<>() {
@Override
public JavaArchive get() {
return ShrinkWrap.create(JavaArchive.class)
.addClasses(Person.class, CustomSerializationResource.class, User.class, Views.class);
}
});

@Test
public void testCustomSerialization() {
// 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("/custom-serialization/person")
.then()
.statusCode(200)
.contentType("application/json")
.body(containsString("Bob"))
.body(containsString("Builder"));

// assert with a list of people
RestAssured
.with()
.body("[{\"first\": \"Bob\", \"last\": \"Builder\"}, {\"first\": \"Bob2\", \"last\": \"Builder2\"}]")
.contentType("application/json; charset=utf-8")
.post("/custom-serialization/people/list")
.then()
.statusCode(200)
.contentType("application/json")
.body(containsString("Bob"))
.body(containsString("Builder"))
.body(containsString("Bob2"))
.body(containsString("Builder2"));

// a new instance should have been created
int currentCount = CustomSerializationResource.UnquotedFieldsPersonSerialization.count.get();
RestAssured.get("/custom-serialization/invalid-use-of-custom-serializer")
.then()
.statusCode(500);
assertEquals(currentCount + 1, CustomSerializationResource.UnquotedFieldsPersonSerialization.count.intValue());
}

@Test
public void testCustomDeserialization() {
// assert that the reader support the unquoted fields (because we have used a custom object reader
// via `@CustomDeserialization`
RestAssured.given()
.body("{first: \"Hello\", last: \"Deserialization\"}")
.contentType("application/json; charset=utf-8")
.post("/custom-serialization/person")
.then()
.statusCode(200)
.contentType("application/json")
.body(containsString("Hello"))
.body(containsString("Deserialization"));

// 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("/custom-serialization/person")
.then()
.statusCode(200);

// assert with a list of people
RestAssured
.with()
.body("[{first: \"Bob\", last: \"Builder\"}, {first: \"Bob2\", last: \"Builder2\"}]")
.contentType("application/json; charset=utf-8")
.post("/custom-serialization/people/list")
.then()
.statusCode(200)
.contentType("application/json")
.body(containsString("Bob"))
.body(containsString("Builder"))
.body(containsString("Bob2"))
.body(containsString("Builder2"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/
@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 })
@Target({ ElementType.METHOD, ElementType.TYPE })
public @interface CustomDeserialization {

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/
@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 })
@Target({ ElementType.METHOD, ElementType.TYPE })
public @interface CustomSerialization {

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ public void recordJsonView(String methodId, String className) {
jsonViewMap.put(methodId, loadClass(className));
}

public void recordCustomSerialization(String methodId, String className) {
customSerializationMap.put(methodId, loadClass(className));
public void recordCustomSerialization(String target, String className) {
customSerializationMap.put(target, loadClass(className));
}

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

public void configureShutdown(ShutdownContext shutdownContext) {
Expand All @@ -51,12 +51,22 @@ 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, ObjectWriter>> customSerializationForClass(Class<?> clazz) {
return (Class<? extends BiFunction<ObjectMapper, Type, ObjectWriter>>) customSerializationMap.get(clazz.getName());
}

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

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

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

0 comments on commit 74cf787

Please sign in to comment.