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 using @CustomSerialization and @CustomDeserialization at class level #35158

Merged
merged 1 commit into from
Aug 3, 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
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