From 750ca7a400b75e39ec1b7f4b435fc32613c66cb1 Mon Sep 17 00:00:00 2001 From: Georgios Andrianakis Date: Mon, 23 Jan 2023 14:30:11 +0200 Subject: [PATCH] Use custom ObjectMapper for Keycloak admin client if necessary Fixes: #30516 --- .../ResteasyReactiveClientProvider.java | 76 +++++++++++++++---- .../it/keycloak/AdminClientResource.java | 12 +-- .../SnakeCaseObjectMapperCustomizer.java | 21 +++++ .../io/quarkus/it/keycloak/UsersResource.java | 17 +++++ .../it/keycloak/AdminClientTestCase.java | 19 ++--- 5 files changed, 115 insertions(+), 30 deletions(-) create mode 100644 integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/SnakeCaseObjectMapperCustomizer.java diff --git a/extensions/keycloak-admin-client-reactive/runtime/src/main/java/io/quarkus/keycloak/admin/client/reactive/runtime/ResteasyReactiveClientProvider.java b/extensions/keycloak-admin-client-reactive/runtime/src/main/java/io/quarkus/keycloak/admin/client/reactive/runtime/ResteasyReactiveClientProvider.java index 5d44d077b7d74..09cdac5a52d79 100644 --- a/extensions/keycloak-admin-client-reactive/runtime/src/main/java/io/quarkus/keycloak/admin/client/reactive/runtime/ResteasyReactiveClientProvider.java +++ b/extensions/keycloak-admin-client-reactive/runtime/src/main/java/io/quarkus/keycloak/admin/client/reactive/runtime/ResteasyReactiveClientProvider.java @@ -1,9 +1,15 @@ package io.quarkus.keycloak.admin.client.reactive.runtime; +import java.util.List; + +import javax.enterprise.inject.Instance; import javax.net.ssl.SSLContext; +import javax.ws.rs.Priorities; import javax.ws.rs.client.Client; import javax.ws.rs.client.WebTarget; +import javax.ws.rs.core.MediaType; +import org.eclipse.microprofile.config.ConfigProvider; import org.jboss.resteasy.reactive.client.impl.ClientBuilderImpl; import org.jboss.resteasy.reactive.client.impl.WebTargetImpl; import org.jboss.resteasy.reactive.server.jackson.JacksonBasicMessageBodyReader; @@ -14,10 +20,14 @@ import io.quarkus.arc.Arc; import io.quarkus.arc.ArcContainer; import io.quarkus.arc.InstanceHandle; +import io.quarkus.jackson.ObjectMapperCustomizer; import io.quarkus.rest.client.reactive.jackson.runtime.serialisers.ClientJacksonMessageBodyWriter; public class ResteasyReactiveClientProvider implements ResteasyClientProvider { + private static final List HANDLED_MEDIA_TYPES = List.of(MediaType.APPLICATION_JSON); + private static final int PROVIDER_PRIORITY = Priorities.USER + 100; // ensures that it will be used first + @Override public Client newRestEasyClient(Object messageHandler, SSLContext sslContext, boolean disableTrustManager) { ClientBuilderImpl clientBuilder = new ClientBuilderImpl(); @@ -32,29 +42,65 @@ private ClientBuilderImpl registerJacksonProviders(ClientBuilderImpl clientBuild throw new IllegalStateException(this.getClass().getName() + " should only be used in a Quarkus application"); } else { InstanceHandle objectMapperInstance = arcContainer.instance(ObjectMapper.class); - ObjectMapper objectMapper = null; + boolean canReuseObjectMapper = canReuseObjectMapper(objectMapperInstance, arcContainer); + if (canReuseObjectMapper) { - InstanceHandle readerInstance = arcContainer - .instance(JacksonBasicMessageBodyReader.class); - if (readerInstance.isAvailable()) { - clientBuilder = clientBuilder.register(readerInstance.get()); - } else { - objectMapper = getObjectMapper(objectMapper, objectMapperInstance); - clientBuilder = clientBuilder.register(new JacksonBasicMessageBodyReader(objectMapper)); - } + ObjectMapper objectMapper = null; - InstanceHandle writerInstance = arcContainer - .instance(ClientJacksonMessageBodyWriter.class); - if (writerInstance.isAvailable()) { - clientBuilder = clientBuilder.register(writerInstance.get()); + InstanceHandle readerInstance = arcContainer + .instance(JacksonBasicMessageBodyReader.class); + if (readerInstance.isAvailable()) { + clientBuilder = clientBuilder.register(readerInstance.get()); + } else { + objectMapper = getObjectMapper(objectMapper, objectMapperInstance); + clientBuilder = clientBuilder.register(new JacksonBasicMessageBodyReader(objectMapper)); + } + + InstanceHandle writerInstance = arcContainer + .instance(ClientJacksonMessageBodyWriter.class); + if (writerInstance.isAvailable()) { + clientBuilder = clientBuilder.register(writerInstance.get()); + } else { + objectMapper = getObjectMapper(objectMapper, objectMapperInstance); + clientBuilder = clientBuilder.register(new ClientJacksonMessageBodyWriter(objectMapper)); + } } else { - objectMapper = getObjectMapper(objectMapper, objectMapperInstance); - clientBuilder = clientBuilder.register(new ClientJacksonMessageBodyWriter(objectMapper)); + ObjectMapper newObjectMapper = new ObjectMapper(); + clientBuilder = clientBuilder + .registerMessageBodyReader(new JacksonBasicMessageBodyReader(newObjectMapper), Object.class, + HANDLED_MEDIA_TYPES, true, + PROVIDER_PRIORITY) + .registerMessageBodyWriter(new ClientJacksonMessageBodyWriter(newObjectMapper), Object.class, + HANDLED_MEDIA_TYPES, true, PROVIDER_PRIORITY); } + } return clientBuilder; } + // the idea is to only reuse the ObjectMapper if no known customizations would break Keycloak + // TODO: in the future we could also look into checking the ObjectMapper bean itself to see how it has been configured + private boolean canReuseObjectMapper(InstanceHandle objectMapperInstance, ArcContainer arcContainer) { + if (objectMapperInstance.isAvailable() && !objectMapperInstance.getBean().isDefaultBean()) { + // in this case a user provided a completely custom ObjectMapper, so we can't use it + return false; + } + + Instance customizers = arcContainer.beanManager().createInstance() + .select(ObjectMapperCustomizer.class); + if (!customizers.isUnsatisfied()) { + // ObjectMapperCustomizer can make arbitrary changes, so in order to be safe we won't allow reuse + return false; + } + // if any Jackson properties were configured, disallow reuse - this is done in order to provide forward compatibility with new Jackson configuration options + for (String propertyName : ConfigProvider.getConfig().getPropertyNames()) { + if (propertyName.startsWith("io.quarkus.jackson")) { + return false; + } + } + return true; + } + // the whole idea here is to reuse the ObjectMapper instance private ObjectMapper getObjectMapper(ObjectMapper value, InstanceHandle objectMapperInstance) { diff --git a/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/AdminClientResource.java b/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/AdminClientResource.java index 970a88280b747..fa99da14b2a70 100644 --- a/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/AdminClientResource.java +++ b/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/AdminClientResource.java @@ -29,22 +29,22 @@ public class AdminClientResource { Keycloak keycloak; @GET - @Produces(MediaType.APPLICATION_JSON) + @Produces(MediaType.TEXT_PLAIN) @Path("realm") - public RealmRepresentation getRealm() { - return keycloak.realm("quarkus").toRepresentation(); + public String getRealm() { + return keycloak.realm("quarkus").toRepresentation().getRealm(); } @GET - @Produces(MediaType.APPLICATION_JSON) + @Produces(MediaType.TEXT_PLAIN) @Path("newrealm") - public RealmRepresentation createRealm() { + public String createRealm() { RealmRepresentation newRealm = createRealm("quarkus2"); newRealm.getClients().add(createClient("quarkus-app2")); newRealm.getUsers().add(createUser("alice", "user")); keycloak.realms().create(newRealm); - return keycloak.realm("quarkus2").toRepresentation(); + return keycloak.realm("quarkus2").toRepresentation().getRealm(); } private static RealmRepresentation createRealm(String name) { diff --git a/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/SnakeCaseObjectMapperCustomizer.java b/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/SnakeCaseObjectMapperCustomizer.java new file mode 100644 index 0000000000000..56f297f6d39ef --- /dev/null +++ b/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/SnakeCaseObjectMapperCustomizer.java @@ -0,0 +1,21 @@ +package io.quarkus.it.keycloak; + +import javax.inject.Singleton; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; + +import io.quarkus.jackson.ObjectMapperCustomizer; + +/** + * This class is used to alter the global ObjectMapper quarkus uses. + * We ensure that KeyCloak Admin Client continues to work despite this. + */ +@Singleton +public class SnakeCaseObjectMapperCustomizer implements ObjectMapperCustomizer { + + @Override + public void customize(ObjectMapper mapper) { + mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); + } +} diff --git a/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/UsersResource.java b/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/UsersResource.java index 8424d2b447709..9ac559a75da9a 100644 --- a/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/UsersResource.java +++ b/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/UsersResource.java @@ -1,9 +1,17 @@ package io.quarkus.it.keycloak; +import java.lang.reflect.Type; +import java.util.function.BiFunction; + import javax.inject.Inject; import javax.ws.rs.GET; import javax.ws.rs.Path; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; + +import io.quarkus.resteasy.reactive.jackson.CustomSerialization; import io.quarkus.security.identity.SecurityIdentity; @Path("/api/users") @@ -14,6 +22,7 @@ public class UsersResource { @GET @Path("/me") + @CustomSerialization(ProperCaseFunction.class) // needed because otherwise SnakeCaseObjectMapperCustomizer causes the result to not be usable by Keycloak public User me() { return new User(keycloakSecurityContext); } @@ -30,4 +39,12 @@ public String getUserName() { return userName; } } + + public static class ProperCaseFunction implements BiFunction { + + @Override + public ObjectWriter apply(ObjectMapper objectMapper, Type type) { + return objectMapper.copy().setPropertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE).writer(); + } + } } diff --git a/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/AdminClientTestCase.java b/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/AdminClientTestCase.java index 3762f3af8c246..ad1b1b41cad20 100644 --- a/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/AdminClientTestCase.java +++ b/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/AdminClientTestCase.java @@ -1,10 +1,9 @@ package io.quarkus.it.keycloak; -import static io.restassured.RestAssured.given; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.restassured.RestAssured.when; +import static org.hamcrest.Matchers.equalTo; import org.junit.jupiter.api.Test; -import org.keycloak.representations.idm.RealmRepresentation; import io.quarkus.test.junit.QuarkusTest; @@ -13,15 +12,17 @@ public class AdminClientTestCase { @Test public void testGetExistingRealm() { - RealmRepresentation realm = given() - .when().get("/admin-client/realm").as(RealmRepresentation.class); - assertEquals("quarkus", realm.getRealm()); + when().get("/admin-client/realm") + .then() + .statusCode(200) + .body(equalTo("quarkus")); } @Test public void testGetNewRealm() { - RealmRepresentation realm = given() - .when().get("/admin-client/newrealm").as(RealmRepresentation.class); - assertEquals("quarkus2", realm.getRealm()); + when().get("/admin-client/newrealm") + .then() + .statusCode(200) + .body(equalTo("quarkus2")); } }