Skip to content

Commit

Permalink
Use custom ObjectMapper for Keycloak admin client if necessary
Browse files Browse the repository at this point in the history
Fixes: #30516
  • Loading branch information
geoand committed Jan 24, 2023
1 parent 4e32c07 commit 750ca7a
Show file tree
Hide file tree
Showing 5 changed files with 115 additions and 30 deletions.
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<String> 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();
Expand All @@ -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<ObjectMapper> objectMapperInstance = arcContainer.instance(ObjectMapper.class);
ObjectMapper objectMapper = null;
boolean canReuseObjectMapper = canReuseObjectMapper(objectMapperInstance, arcContainer);
if (canReuseObjectMapper) {

InstanceHandle<JacksonBasicMessageBodyReader> 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<ClientJacksonMessageBodyWriter> writerInstance = arcContainer
.instance(ClientJacksonMessageBodyWriter.class);
if (writerInstance.isAvailable()) {
clientBuilder = clientBuilder.register(writerInstance.get());
InstanceHandle<JacksonBasicMessageBodyReader> 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<ClientJacksonMessageBodyWriter> 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<ObjectMapper> 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<ObjectMapperCustomizer> 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<ObjectMapper> objectMapperInstance) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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);
}
Expand All @@ -30,4 +39,12 @@ public String getUserName() {
return userName;
}
}

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

@Override
public ObjectWriter apply(ObjectMapper objectMapper, Type type) {
return objectMapper.copy().setPropertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE).writer();
}
}
}
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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"));
}
}

0 comments on commit 750ca7a

Please sign in to comment.